Compare commits
279 Commits
5e4ef2a327
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 056d4b02a4 | |||
| 2f945dccd9 | |||
| 5b840ea510 | |||
| 185a044c5e | |||
| 0e84248d38 | |||
| db3c870910 | |||
| e23a462da9 | |||
| bc04af9c77 | |||
| 33c2ffa5ff | |||
| 08d8de60db | |||
| 27196f9e78 | |||
| 4d721b6602 | |||
| 5274df8909 | |||
| 0c7df28b6d | |||
| e6a9ff4ec8 | |||
| 2bf1691fb8 | |||
| 979ea92ce7 | |||
| 0ec36b03a7 | |||
| 7bbd236a17 | |||
| f53469f197 | |||
| 6272200730 | |||
| bcfd54489b | |||
| d6e27666a3 | |||
| 52fbf375d2 | |||
| cf06e28e8d | |||
| f72264c4af | |||
| 1a6c8d4740 | |||
| e76252505f | |||
| 255a593b50 | |||
| 1838ab0401 | |||
| 0268d507d4 | |||
| 45259ae9ba | |||
| 2a4337e030 | |||
| 8b213c540e | |||
| 356a0bdf76 | |||
| 462970251e | |||
| 0e6e7fa9ba | |||
| 4bed48d69e | |||
| 7c96bc8304 | |||
| ecea8a0c22 | |||
| 189dff5255 | |||
| 81808dbc10 | |||
| 4dad3d21a8 | |||
| 71403efafe | |||
| b22a0eca5b | |||
| 5a65ec9590 | |||
| f693a41692 | |||
| 80a3eef6ec | |||
| 52daaf8611 | |||
| 063446239c | |||
| 44dcb3c9d9 | |||
| ddcd9396b0 | |||
| e9c16325cc | |||
| 863284e540 | |||
| 5621b818cc | |||
| 9ac69cdf44 | |||
| 242312441d | |||
| b0ec8b4982 | |||
| ffaed3f544 | |||
| 8557bfe230 | |||
| bdbdb4a010 | |||
| a99ae432fc | |||
| cefb235f6d | |||
| 6365136275 | |||
| d0a11b82cd | |||
| e237bf1e8b | |||
| 9652a436e0 | |||
| 461151cfec | |||
| ae276fb518 | |||
| 9e9ca9ca28 | |||
| 68732aa10f | |||
| d6f7993504 | |||
| 6fb8375ad6 | |||
| 4ab6b9fca5 | |||
| 63a45d4ee2 | |||
| d819f2e285 | |||
| 43f25e2e0d | |||
| 7f386d19ea | |||
| 4a6c48e81c | |||
| 6ec31b5275 | |||
| 2736c3d6e0 | |||
| d4ba3bce7a | |||
| 4d4890034a | |||
| 8c96d9b78e | |||
| 5ccbfec92d | |||
| d30bb0e9f0 | |||
| 9a1f8a7136 | |||
| c254cf5e2d | |||
| 2c466bf5a2 | |||
| 63496a8e3b | |||
| 6016053a92 | |||
| 8c95e50554 | |||
| 36a6c21bfd | |||
| d889353cbe | |||
| 38e7731a3d | |||
| e28abdce25 | |||
| c72dbc7963 | |||
| 2f7669b15b | |||
| ff8a623e46 | |||
| e04f97c247 | |||
| 25aa118e4b | |||
| 1f87f6d1c7 | |||
| 0752b7dc0b | |||
| e250b6c5e1 | |||
| 12bc9e4220 | |||
| b73a8fb334 | |||
| 960e07d7d7 | |||
| 0b497642f4 | |||
| 2af5c469d6 | |||
| 78a8017e95 | |||
| ce287ccac2 | |||
| 0f680780a4 | |||
| 97a1482bb5 | |||
| 9a4999ccbc | |||
| 9d70192da3 | |||
| 8e73621d85 | |||
| 7e2854fe8d | |||
| 09c7461892 | |||
| be95b40d79 | |||
| 02187ee129 | |||
| 24bcc42655 | |||
| af83805c34 | |||
| 0124254ea9 | |||
| 3cd9e6149b | |||
| d1e3f7230c | |||
| 553e128066 | |||
| cef11b26f3 | |||
| 4cc91c461b | |||
| ff5a334e95 | |||
| 6f23dfebbf | |||
| 9b0e390dfd | |||
| 90efcc5b00 | |||
| 4af38d7e65 | |||
| 17dc42e645 | |||
| b524c1d488 | |||
| b1411625aa | |||
| 26f72930c3 | |||
| 3f1d122538 | |||
| d622d74434 | |||
| c300aab223 | |||
| 5461382b8f | |||
| 852a109c69 | |||
| d2994ce919 | |||
| 534ce46096 | |||
| 67135da4f7 | |||
| 0fd81df700 | |||
| a08922e9a0 | |||
| f38907991d | |||
| 4a58021670 | |||
| 25c0d073c4 | |||
| e1ebe6d694 | |||
| 4fa5d4bc2d | |||
| 4e97656a75 | |||
| b6a868df5e | |||
| 49712f7c7a | |||
| e46bec2e32 | |||
| 84b0003070 | |||
| 959b98c93a | |||
| b452c0a284 | |||
| b4d44b2731 | |||
| fa7253a64c | |||
| 5fe19e5006 | |||
| f9cd8de8fd | |||
| 0e16f68b4b | |||
| da85a2c0d6 | |||
| 44522db34e | |||
| 355177a50a | |||
| 137d32d28d | |||
| 3ad1809af6 | |||
| 391725e663 | |||
| bf1f9646e2 | |||
| 6b35de458d | |||
| c0acc836f0 | |||
| 8f88ccf581 | |||
| dd8d62e3b3 | |||
| ff9aaeb338 | |||
| d1cc742a59 | |||
| 4f85a179cf | |||
| af64fc39e3 | |||
| b1cd03be51 | |||
| 2be6b99667 | |||
| bfb7dec1e9 | |||
| edefdd66d8 | |||
| bdd8cb89a3 | |||
| f4222b715f | |||
| 90fff7b305 | |||
| 8ba3519090 | |||
| e1ddf2cda1 | |||
| 172db54e7a | |||
| 80d73e8afd | |||
| cf57c5f1a5 | |||
| b8e560e75b | |||
| f337820fc8 | |||
| 17149937a6 | |||
| 70253e83a5 | |||
| f97ceab3ad | |||
| 0a0beb9eb3 | |||
| c234b87dea | |||
| 4fccf21676 | |||
| 0354ab8768 | |||
| 7276d06a18 | |||
| 5a048488ea | |||
| 146f809e9b | |||
| 7365180550 | |||
| fb251154c7 | |||
| 5ff33c92d9 | |||
| 3c28c771fd | |||
| e999d629c5 | |||
| 2ea01ce2ce | |||
| ba182cb180 | |||
| 1354ca6b06 | |||
| d8b12da36d | |||
| 99cef7c393 | |||
| 41f2a644d3 | |||
| d504de0645 | |||
| 3b99f1f332 | |||
| f115af50ab | |||
| 23b6defa68 | |||
| 2848b5286f | |||
| 600c92fd0f | |||
| e56f2040e8 | |||
| 7319501ee6 | |||
| 7fc378f427 | |||
| edf3845375 | |||
| 188dc8247e | |||
| 126033e4dd | |||
| 2d76f10c1c | |||
| 91dfd4807a | |||
| e5e82b3634 | |||
| 5aaf1cd958 | |||
| 8a751fe441 | |||
| 6bf7435434 | |||
| 8b3e5b2b8f | |||
| ec55e5808e | |||
| 17b25d492a | |||
| 367985ba44 | |||
| 899b770cfa | |||
| 484f2ffe6d | |||
| fb38c297bd | |||
| 39da68cd87 | |||
| 25d1cebd12 | |||
| 440ff22bda | |||
| 5fc86d2b5e | |||
| f5976e6865 | |||
| 163efe1392 | |||
| 6230648f29 | |||
| 2a3c4f2ab5 | |||
| 1afcad25d3 | |||
| c11c492d86 | |||
| 78d70fa4ca | |||
| e1180f8e34 | |||
| bc2834e867 | |||
| 7a43be9764 | |||
| 4c62b9b529 | |||
| 64f457dd87 | |||
| 12418a3d52 | |||
| b1f8b71104 | |||
| 509de6ef97 | |||
| 7b9fba04ce | |||
| d5562c71ef | |||
| 3548f773c9 | |||
| 7980f9672d | |||
| 2c6e8e5ce4 | |||
| 9794193803 | |||
| 7e979ecfab | |||
| dc4c1ff0bf | |||
| b98d624359 | |||
| a84f7c81f4 | |||
| 7c57cec92a | |||
| b10ce7c0a5 | |||
| 838ea7f362 | |||
| 619d8247f7 | |||
| f7f1a238e5 | |||
| 4395ff6ac6 | |||
| 6448c9eea4 | |||
| 2c79ed445c | |||
| 3e730fdf2b | |||
| 12c6ec7970 | |||
| a310c1e6f2 |
@@ -0,0 +1,79 @@
|
||||
---
|
||||
description: Zeigt den aktuellen Stand von DM360 - was zu tun ist, und ob Repo, Versionen und ausgelieferte App zusammenpassen.
|
||||
---
|
||||
|
||||
> **Wo dieser Befehl greift.** Claude Code sucht Projektbefehle ausschliesslich
|
||||
> in `<Arbeitsverzeichnis>/.claude/commands/`. Seit dem Umzug der Arbeitskopie
|
||||
> nach `C:\Users\tobia\Projekte\audi-app` (10.09.2026) wird das Repository direkt
|
||||
> geoeffnet - `/stand` steht damit ohne Zutun zur Verfuegung. Der frueher noetige
|
||||
> Zeiger unter `.claude\sessions\.claude\commands\stand.md` entfaellt; er war
|
||||
> unversioniert und ging mit jeder verlorenen Arbeitsumgebung mit. Wird doch
|
||||
> einmal eine Ebene darueber gearbeitet, greift dieselbe Regel wie zuvor: dort
|
||||
> findet Claude Code diese Datei nicht.
|
||||
|
||||
Zeige den aktuellen Stand dieses Projekts. Zwei Teile: den **gemessenen**
|
||||
(unten selbst nachrechnen, nie aus dem Gedächtnis) und den **gepflegten** (aus
|
||||
`OFFEN.md`).
|
||||
|
||||
## 1. Messen
|
||||
|
||||
Führe genau diesen Block aus:
|
||||
|
||||
```bash
|
||||
R=$(git rev-parse --show-toplevel) && cd "$R"
|
||||
echo "== Repository =="
|
||||
echo " Zweig $(git branch --show-current) | HEAD $(git rev-parse --short HEAD) | origin $(git rev-parse --short @{u} 2>/dev/null || echo '?')"
|
||||
printf " Arbeitsbaum: "; [ -z "$(git status --porcelain)" ] && echo sauber || git status --short
|
||||
echo "== Versionen =="
|
||||
python - <<'PY'
|
||||
import json, hashlib, pathlib, zipfile, plistlib, re
|
||||
b = "custom_components/audi_dashboard/"
|
||||
m = json.load(open(b + "manifest.json"))["version"]
|
||||
print(f" Integration {m}")
|
||||
try:
|
||||
j = json.load(open(b + "frontend/app/bundle.json"))
|
||||
z = pathlib.Path(b + "frontend/app/bundle.zip").read_bytes()
|
||||
ok = j["version"] == m and hashlib.sha256(z).hexdigest() == j["sha256"] and len(z) == j["bytes"]
|
||||
print(f" OTA-Buendel {j['version']} {'passt' if ok else 'PASST NICHT - npm run ota faellig'}")
|
||||
except Exception as e:
|
||||
print(" OTA-Buendel nicht lesbar:", e)
|
||||
try:
|
||||
zf = zipfile.ZipFile("companion-app/auslieferung/App.ipa")
|
||||
n = zf.namelist()
|
||||
p = plistlib.loads(zf.read([x for x in n if re.match(r"Payload/[^/]+\.app/Info\.plist$", x)][0]))
|
||||
print(f" .ipa auf dem Telefon {p.get('CFBundleShortVersionString')}")
|
||||
c = [x for x in n if re.match(r"Payload/[^/]+\.app/capacitor\.config\.json$", x)]
|
||||
if c:
|
||||
L = json.loads(zf.read(c[0])).get("packageClassList", [])
|
||||
fehlt = [k for k in ("BelegZwischenablagePlugin", "KalenderTerminPlugin") if k not in L]
|
||||
print(" eigene Plugins registriert:", "ja" if not fehlt else "NEIN - " + ", ".join(fehlt))
|
||||
except Exception as e:
|
||||
print(" .ipa nicht lesbar:", e)
|
||||
PY
|
||||
echo "== Wartet auf einen Xcode-Lauf =="
|
||||
B=$(git log --format=%H -1 -- companion-app/auslieferung/App.ipa)
|
||||
N=$(git log --oneline "$B..HEAD" -- companion-app/native/ companion-app/scripts/ios-*.sh companion-app/scripts/ios-*.mjs companion-app/capacitor.config.ts)
|
||||
[ -z "$N" ] && echo " keine nativen Aenderungen seit der ausgelieferten .ipa" || echo "$N" | sed 's/^/ /'
|
||||
```
|
||||
|
||||
## 2. Lesen
|
||||
|
||||
Lies danach `OFFEN.md`. Steht dort etwas unter „Wartet auf einen Xcode-Lauf",
|
||||
oder meldet die Messung nicht registrierte Plugins, lies zusaetzlich
|
||||
`APPLE_DEV_BACKLOG.md`.
|
||||
|
||||
## 3. Berichten
|
||||
|
||||
Kurz und in dieser Reihenfolge:
|
||||
|
||||
1. **Stimmt der mechanische Stand?** Nur melden, was *nicht* passt - Repo nicht
|
||||
synchron, Buendel-Version abweichend, Pluginliste unvollstaendig, native
|
||||
Aenderungen aufgelaufen. Passt alles, genuegt ein Satz.
|
||||
2. **Was wartet auf den Eigentuemer** (Abschnitt 1 und 2 von `OFFEN.md`).
|
||||
3. **Was eine Sitzung bauen koennte** (Abschnitt 3), mit einer Empfehlung.
|
||||
|
||||
Nenne die Punkte aus `OFFEN.md`, statt sie neu herzuleiten - und wenn dir beim
|
||||
Messen etwas auffaellt, das dort fehlt, sag es und trage es nach.
|
||||
|
||||
**Starte nichts von selbst.** Ein Xcode-Lauf ist immer die Handlung des
|
||||
Eigentuemers (Apple-Regel in `AGENTS.md`).
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "companion-app",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev", "--prefix", "companion-app"],
|
||||
"port": 5173
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -18,3 +18,10 @@ Thumbs.db
|
||||
|
||||
# Protokolle
|
||||
*.log
|
||||
|
||||
# Arbeitsdateien dieser Werkzeugkette (Patch-Skripte, Zwischenstaende)
|
||||
.tmp-*
|
||||
|
||||
# Python-Bytecode, entsteht beim Kompilieren und gehoert nicht ins Repo
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
# Apple Dev Backlog (DM360)
|
||||
|
||||
Alles, was einen **Xcode-Lauf** oder das **Apple Developer Portal** braucht,
|
||||
wird hier gesammelt - und nur hier.
|
||||
|
||||
**Warum:** ein Xcode-Lauf lohnt sich nicht für eine einzelne native Änderung.
|
||||
Er wird erst gestartet, wenn eine nennenswerte Menge zusammengekommen ist; die
|
||||
Entscheidung darüber trifft der Eigentümer, nicht eine Sitzung.
|
||||
|
||||
## Arbeitsregel (verbindlich)
|
||||
|
||||
* Jede Aufgabe, die Xcode oder das Apple-Portal braucht, kommt **in diese
|
||||
Datei**, in derselben Sitzung, in der sie auffällt - nicht in eine Notiz im
|
||||
Bericht und nicht nur in `AGENTS.md`.
|
||||
* **Nichts davon wird von sich aus gestartet.** Ein Xcode-Lauf ist eine
|
||||
Handlung des Eigentümers am Mac; eine Sitzung bereitet ihn vor und meldet,
|
||||
wenn sich genug angesammelt hat.
|
||||
* Was **ohne** Xcode geht, gehört nicht hierher: Änderungen an der Oberfläche
|
||||
der App reisen im OTA-Bündel (`@capgo/capacitor-updater`), Änderungen am Panel
|
||||
und am Backend über die Selbstaktualisierung der Integration. Nur nativer
|
||||
Code, Plugins, Berechtigungen, Signatur und Auslieferungsweg brauchen einen
|
||||
Lauf.
|
||||
* Erledigtes wird **nicht gelöscht**, sondern nach unten unter „Erledigt"
|
||||
verschoben - mit der Fassung, in der es ausgeliefert wurde. Sonst wird
|
||||
dieselbe Frage in einem halben Jahr wieder aufgemacht.
|
||||
|
||||
---
|
||||
|
||||
## Stand der ausgelieferten App
|
||||
|
||||
Gemessen am Artefakt `companion-app/auslieferung/App.ipa`, nicht aus dem
|
||||
Gedächtnis (06.09.2026):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Fassung | **2026.9.4.17** (Build 1), App und Erweiterung gleich |
|
||||
| Bundle-ID | `app.datametric360`, Erweiterung `app.datametric360.teilen` |
|
||||
| Profile | beide Ad Hoc, **2 Geräte**, gültig bis **2027-08-29** |
|
||||
| Berechtigungen | Standort, Kalender (beide Schlüssel), Kamera - alle vorhanden |
|
||||
| `packageClassList` | **6 Klassen - beide eigenen Plugins fehlen** |
|
||||
|
||||
Zum Vergleich: das Repository steht auf `2026.9.6.5`. Der Abstand ist **kein**
|
||||
Rückstand - alles dazwischen war Oberfläche und Backend und reist über OTA. Was
|
||||
wirklich auf einen Lauf wartet, steht unten.
|
||||
|
||||
---
|
||||
|
||||
## Wie ein Lauf abläuft
|
||||
|
||||
**Diese Datei ist die Übergabe.** Wer am Mac sitzt - mit oder ohne Claude am
|
||||
Xcode - braucht keine weitere: hier steht der Stand, der eine Befehl, und was
|
||||
danach zu prüfen ist. `AGENTS.md` ist nur für das *Warum* nötig.
|
||||
|
||||
### Vorher
|
||||
|
||||
**Nichts vorzubereiten** - auch kein `git pull`. Das Skript holt `origin/main`
|
||||
selbst und bringt den Klon darauf, solange das nachweislich verlustfrei ist:
|
||||
ein reiner Rückstand wird vorgespult, und Commits, die zwar nur lokal liegen,
|
||||
gegenüber dem gemeinsamen Vorfahren aber **keine einzige Datei ändern**, werden
|
||||
verworfen. Beides sagt es an; das Reflog hält Verworfenes noch 90 Tage.
|
||||
|
||||
> **Warum kein `git pull`:** am 07.09.2026 sind drei Commits am Ende von `main`
|
||||
> entfernt und der Branch per Force-Push zurückgesetzt worden. Ein Klon von davor
|
||||
> steht damit **vor** `origin/main` - dort wäre `git pull` genau das Falsche: der
|
||||
> Merge hängt die entfernten Commits wieder in die Historie, und der nächste Push
|
||||
> stellte sie auf dem Server wieder her (er ginge sogar ohne `--force` durch).
|
||||
> Genau diese Lage räumt das Skript von sich aus auf.
|
||||
|
||||
Ab **hier** entscheidet es nichts mehr allein: liegen lokale Commits, die **auch
|
||||
den Inhalt** ändern, bricht es ab und legt dir beide Wege vor (pushen oder
|
||||
verwerfen) - ungepushte Arbeit wirft es nie weg.
|
||||
Ein nicht sauberer Arbeitsbaum bricht den Lauf ebenfalls ab - er ist die
|
||||
Voraussetzung dafür, dass Zurechtrücken oben gefahrlos ist. Und ebenso bricht er
|
||||
ab, wenn das Zielgerät nicht im Team eingetragen ist („Your team has no
|
||||
devices"); das ist dann eine Portal-Sache, kein Projektfehler.
|
||||
|
||||
### Der Lauf
|
||||
|
||||
```bash
|
||||
bash companion-app/scripts/ios-signieren.sh
|
||||
```
|
||||
|
||||
Ein Befehl, von jedem Verzeichnis im Repository aus. Er wechselt selbst nach
|
||||
`companion-app/` und erledigt der Reihe nach: Git-Stand prüfen, Webbündel bauen,
|
||||
native Hülle synchronisieren, Standort-/Kalender-/Kamera-Berechtigung eintragen,
|
||||
Share-Erweiterung einrichten, App-Symbol einspielen, Versionsnummern setzen,
|
||||
Archiv bauen und signieren, `.ipa` exportieren, **die fertige `.ipa` gegenlesen**
|
||||
und die Auslieferung über die Luft vorbereiten.
|
||||
|
||||
**Die Fassung kommt aus `custom_components/audi_dashboard/manifest.json`** - es
|
||||
gibt nichts von Hand hochzuzählen. Beim Schreiben dieser Zeilen wäre das
|
||||
`2026.9.6.5`.
|
||||
|
||||
**Einmalig beim ersten Signieren:** der Schlüsselbund fragt, ob `codesign` den
|
||||
Schlüssel benutzen darf. **„Immer erlauben"** wählen, nicht „Erlauben" - ein
|
||||
Archiv signiert über 25 Programmteile und fragte sonst jedes Mal erneut. Bleibt
|
||||
der Lauf minutenlang beim Signieren stehen, wartet er auf genau diesen Dialog.
|
||||
|
||||
### Danach
|
||||
|
||||
```bash
|
||||
git add companion-app/auslieferung && git commit && git push
|
||||
```
|
||||
|
||||
Das Telefon lädt die `.ipa` aus dem Repository - ohne diesen Push ändert sich
|
||||
dort nichts. Danach in **Safari** auf dem Telefon öffnen (andere Browser reichen
|
||||
`itms-services://` nicht an das System weiter):
|
||||
|
||||
```
|
||||
itms-services://?action=download-manifest&url=https://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/companion-app/auslieferung/manifest.plist
|
||||
```
|
||||
|
||||
Die Adresse trägt **kein** `?v=` - ein zweites Fragezeichen zerlegt den
|
||||
Parameter, und weil iOS die Rückfrage erst aus dem geladenen Manifest baut,
|
||||
passiert dann sichtbar gar nichts (Abschnitt CK).
|
||||
|
||||
Zum Schluss die eine Prüfung, die kein Skript abnehmen kann - siehe Abschnitt C
|
||||
unten.
|
||||
|
||||
---
|
||||
|
||||
## A. Wartet auf einen Xcode-Lauf
|
||||
|
||||
### A1. Zwei eigene Plugins sind auf dem Telefon tot
|
||||
|
||||
**Auswirkung: Zwischenablage und Kalendereintrag funktionieren nicht.** Das ist
|
||||
die einzige Position mit sichtbarem Ausfall.
|
||||
|
||||
Capacitor 8 registriert ausschließlich, was in `packageClassList` der
|
||||
`capacitor.config.json` im Bündel steht (`CapacitorBridge.swift`,
|
||||
`registerPlugins()`). Diese Liste erzeugt `cap sync` aus den installierten
|
||||
npm-Paketen - app-eigene Plugins sind keine. Beide Klassen liegen nachweislich
|
||||
im Programm der ausgelieferten `.ipa` und werden trotzdem nie registriert.
|
||||
|
||||
*Korrektur liegt im Repo* (`a99ae43`): `ios-teilen-einrichten.mjs` trägt die
|
||||
Klassen nach jedem `cap sync` nach und liest ihre Namen aus den Swift-Dateien
|
||||
selbst, damit sie nicht an zwei Stellen gepflegt werden. `ios-signieren.sh`
|
||||
**bricht ab**, wenn eine davon im fertigen Bündel fehlt.
|
||||
|
||||
Hintergrund vollständig: `AGENTS.md`, Abschnitt DH und CM.
|
||||
|
||||
### A2. Installationslink ohne zweites Fragezeichen
|
||||
|
||||
`ios-luftweg.sh` hängte den Cache-Brecher `?v=` auch an die Manifest-Adresse
|
||||
**innerhalb** des `itms-services`-Links - damit steht dort ein zweites `?`, der
|
||||
Abruf scheitert, und weil iOS die Rückfrage erst aus dem geladenen Manifest
|
||||
baut, passiert sichtbar gar nichts.
|
||||
|
||||
*Korrektur liegt im Repo* (`6365136`). Die bereits ausgelieferte
|
||||
`auslieferung/index.html` wurde von Hand nachgezogen; das Skript zieht beim
|
||||
nächsten Lauf nach. Hintergrund: `AGENTS.md`, Abschnitt CK.
|
||||
|
||||
---
|
||||
|
||||
## B. Wartet auf das Apple Developer Portal
|
||||
|
||||
Derzeit **nichts offen.** App-Gruppe, beide App-IDs und die Gerätefreigabe sind
|
||||
eingerichtet (Abschnitt AK), die Profile laufen bis 2027-08-29.
|
||||
|
||||
Vorzumerken, aber noch lange hin:
|
||||
|
||||
* **2027-07-17** - das Entwicklerzertifikat läuft ab.
|
||||
* **2027-08-29** - beide Bereitstellungsprofile laufen ab.
|
||||
* Ein **neues Gerät** braucht eine Registrierung im Portal *und* frisch
|
||||
ausgestellte Profile. Ein bereits ausgestelltes Profil erfährt von einem neuen
|
||||
Gerät nichts - deshalb räumt `ios-signieren.sh` die zwischengespeicherten
|
||||
Profile vor dem Bauen weg (Abschnitt AI).
|
||||
|
||||
---
|
||||
|
||||
## C. Nach dem Lauf zu prüfen
|
||||
|
||||
Am **fertigen Bündel**, nicht am Projekt. Die Begründung steht in Abschnitt AK:
|
||||
von fünf Fehlern der nativen Hülle haben vier einen erfolgreichen Archivlauf
|
||||
erzeugt und sind erst am entpackten `.ipa` aufgefallen.
|
||||
|
||||
Das Skript prüft selbst und bricht ab, wenn etwas fehlt:
|
||||
|
||||
1. Die Erweiterung hat ein echtes Programm.
|
||||
2. App und Erweiterung tragen dieselbe `CFBundleShortVersionString` **und** eine
|
||||
brauchbare `CFBundleVersion` (ohne die lehnt iOS die Installation der ganzen
|
||||
App ab - Abschnitt CD).
|
||||
3. Die App-Gruppe steht in den Entitlements **beider** Ziele.
|
||||
4. Beide eigenen Plugin-Klassen stehen in `packageClassList`.
|
||||
|
||||
**Von Hand bleibt genau eine Prüfung** - auf dem Gerät: einen Beleg über das
|
||||
Teilen-Blatt schicken, ein PDF aus der Zwischenablage einfügen, einen
|
||||
Kalendereintrag anlegen. Ob die Plugins *registriert* sind, sagt Punkt 4; ob sie
|
||||
*tun*, was sie sollen, sagt nur das Telefon.
|
||||
|
||||
---
|
||||
|
||||
## Was der Lauf von selbst erledigt
|
||||
|
||||
Damit es niemand ein zweites Mal von Hand macht: `ios-signieren.sh` trägt bei
|
||||
jedem Lauf Standort-, Kalender- und Kamera-Berechtigung ein, richtet die
|
||||
Share-Erweiterung ein, spielt das App-Symbol ein und setzt die Versionsnummern
|
||||
in beide Ziele. Das muss sein, weil `companion-app/ios/` gitignored ist und von
|
||||
`npx cap add ios` jederzeit neu erzeugt wird - alles, was man in Xcode
|
||||
anklickt, wäre danach weg (Abschnitt AI).
|
||||
|
||||
Unvermeidlich von Hand bleiben: die einmalige Portal-Einrichtung, das
|
||||
Schlüsselbund-„Immer erlauben" beim ersten Signieren, und `git push` der
|
||||
fertigen `.ipa`, weil das Telefon sie aus dem Repository lädt.
|
||||
|
||||
---
|
||||
|
||||
## Erledigt
|
||||
|
||||
| Fassung | Was |
|
||||
|---|---|
|
||||
| 2026.9.4.17 | Kamera-Berechtigung für den QR-Scanner |
|
||||
| 2026.9.4.7 | `CFBundleVersion` in beiden Bündeln - die App war vorher nicht installierbar |
|
||||
| 2026.9.4.5 | Versionsnummern von App und Erweiterung angeglichen |
|
||||
| 2026.9.2.7 | Share-Erweiterung, Zwischenablage- und Kalender-Plugin gebaut und signiert |
|
||||
| 2026.8.30.8 | Erste signierte `.ipa`, Luftweg über Gitea eingerichtet |
|
||||
@@ -0,0 +1,89 @@
|
||||
# Audi-CI-Design — Archiv (Stand 2026-08-13, vor der iOS-Offizialisierung)
|
||||
|
||||
**Warum diese Datei existiert:** `SPECIFICATION.md` §2 „Concept & Design System" beschrieb bis
|
||||
2026-08-13 das ursprüngliche, reine Audi-CI-Design des Panels — Nacht-Canvas `#161b23`, 20-px-
|
||||
Kachelradius, Rot nur als Akzent, keine Schatten. Mit der Entscheidung, die iOS-Auflage
|
||||
(`audi-dashboard-ios.css`) offiziell zur verbindlichen Optik zu machen (siehe
|
||||
`DESIGN_REVIEW_2026-08-13.md` §A, Option 1, und der entsprechende `AGENTS.md`-Eintrag), wurde §2
|
||||
umgeschrieben, um die iOS-Werte zu beschreiben. Diese Datei bewahrt den vorherigen Wortlaut
|
||||
**wortgleich**, falls die Entscheidung später zurückgenommen wird.
|
||||
|
||||
**Wichtig — das ist nicht die einzige Absicherung:** Der Code selbst macht die Rückkehr trivial,
|
||||
unabhängig von dieser Datei. `audi-dashboard-ios.css` ist eine rein additive **Auflage**
|
||||
(`homeassistant/www/audi-dashboard-app.js`, Zeile ~3990–4000 lädt zuerst `audi-dashboard.css`, dann
|
||||
zusätzlich `audi-dashboard-ios.css`) — die Basisdatei `audi-dashboard.css` enthält die Audi-CI-Werte
|
||||
weiterhin vollständig und unverändert. Um zur reinen Audi-CI zurückzukehren, genügt es, das Laden
|
||||
von `audi-dashboard-ios.css` in `_aufbauen()` zu entfernen bzw. bedingt zu machen — kein Restore
|
||||
aus einem Backup nötig. Diese Datei dokumentiert die **Spezifikationstexte**, nicht den Code (der
|
||||
bleibt ohnehin vorhanden).
|
||||
|
||||
Bekannter Nebenpunkt aus dem Design-Review: die korrigierte `--fg3`-Kontrastfarbe (`#8a94a3`, siehe
|
||||
unten) ist in `bauauftrag.md` §8 **nicht** nachgezogen — dort steht noch der ursprüngliche,
|
||||
kontrastschwache Wert `#657081`. Diese Archivdatei hier gibt daher den zuletzt tatsächlich gültigen
|
||||
Stand wieder, nicht `bauauftrag.md`.
|
||||
|
||||
---
|
||||
|
||||
## Ursprünglicher Wortlaut von `SPECIFICATION.md` §2 (vor 2026-08-13)
|
||||
|
||||
### Visual language
|
||||
Dark ("Nacht") is the default and primary theme; a light ("Tag") theme exists as a toggle. No
|
||||
shadows, no gradients anywhere except the fade under the vehicle photo (`.szene`). Flat tiles,
|
||||
pill-shaped controls, hairline dividers.
|
||||
|
||||
### Colors (CSS custom properties, defined per-theme via `:root` / `[data-theme="tag"]`)
|
||||
|
||||
| Role | Nacht (default) | Tag |
|
||||
|---|---|---|
|
||||
| `--canvas` (page background) | `#161b23` | `#FFFFFF` |
|
||||
| `--tile` | `#1f2733` | `#f2f2f2` |
|
||||
| `--tile-2` (inputs, hover) | `#2a3341` | `#e5e5e5` |
|
||||
| `--line` | `rgba(255,255,255,.10)` | `rgba(0,0,0,.10)` |
|
||||
| `--line-strong` | `rgba(255,255,255,.20)` | `rgba(0,0,0,.22)` |
|
||||
| `--fg` | `#FFFFFF` | `#000000` |
|
||||
| `--fg2` (secondary text) | `#9aa1ad` | `#4c4c4c` |
|
||||
| `--fg3` (labels/eyebrows) | `#8a94a3` | `#666666` |
|
||||
| `--ok` | `#15da15` | `#0DA20D` |
|
||||
| `--warn` | `#ffaa00` | `#ffaa00` |
|
||||
| `--shade` | `rgba(255,255,255,.05)` | `rgba(0,0,0,.04)` |
|
||||
| `--bad` | `#fd2c4e` | `#eb0d3f` |
|
||||
| `--red` (accent, theme-independent) | `#F50537` | `#F50537` |
|
||||
|
||||
`--red` is used sparingly (accents, active states, destructive actions), not as a background fill.
|
||||
|
||||
### Radii
|
||||
- `--r-tile: 20px` — tiles, the vehicle-photo "scene" container.
|
||||
- `--r-pill: 999px` — buttons, switches, segmented controls, progress bars.
|
||||
- Small functional elements (form inputs, placeholder/mini image boxes, popups) use **hardcoded
|
||||
literal pixel values** (6px / 12px / 14px respectively) rather than a shared token.
|
||||
|
||||
### Typography
|
||||
Three font-family names in play, all under the trademarked "Audi Type" family, embedded inline as
|
||||
base64 `woff2` directly in `audi-dashboard.css` (private-install only):
|
||||
- `"Audi Type"` — body/UI text, weight 400.
|
||||
- `"Audi Type Wide"`, weight 300 — `.fig` class, used for large numeric figures (odometer, range,
|
||||
tank %, prices, service countdowns).
|
||||
- `"Audi Type Extended"`, italic — `.sport` class and the vehicle-title badge suffix
|
||||
(`.badge .zusatz`).
|
||||
|
||||
Only weights 300 and 400 are shipped — never use `font-weight: 600` or higher; the browser would
|
||||
synthetically bold. Numbers are formatted via `de-DE` locale (thousands separator, comma decimals)
|
||||
through the `de()`/`eur()` helpers.
|
||||
|
||||
### Licensing constraint (hard rule)
|
||||
The Audi Type font, the four-rings SVG, and the model badge SVGs (`www/badges/*.svg`) are
|
||||
**licensed/trademarked assets, cleared only for this one private, non-published installation**
|
||||
(bauauftrag §8, §12). Never extract, republish, or reuse them anywhere outside this specific Home
|
||||
Assistant instance.
|
||||
|
||||
---
|
||||
|
||||
## So kommst du zurück
|
||||
|
||||
1. In `homeassistant/www/audi-dashboard-app.js`, `_aufbauen()`: die vier Zeilen, die `linkIos`
|
||||
erzeugen und an `ROOT` anhängen, entfernen (oder hinter eine Bedingung stellen).
|
||||
2. `SPECIFICATION.md` §2 durch den obigen Wortlaut ersetzen (oder auf diese Datei verweisen und den
|
||||
iOS-Absatz als „überholt" markieren, symmetrisch zur jetzigen Umkehrung).
|
||||
3. `AGENTS.md` entsprechend nachziehen (die Design-Review-Entscheidung als zurückgenommen
|
||||
vermerken).
|
||||
4. `audi-dashboard-ios.css` selbst muss nicht gelöscht werden — ungeladen hat sie keine Wirkung.
|
||||
@@ -2,10 +2,16 @@
|
||||
|
||||
## Claude Code
|
||||
|
||||
- `AGENTS.md` above is the single source of truth for project state, rules, and open items —
|
||||
edit it there, keep this file as a thin shim.
|
||||
- **`OFFEN.md` says what is to be done right now** — start there, not in AGENTS.md's 12,000
|
||||
lines. `/stand` measures the mechanical half itself (repo, versions, shipped `.ipa`) and reads
|
||||
`OFFEN.md` alongside it. Keep `OFFEN.md` current in the same session something changes.
|
||||
- `AGENTS.md` above is the single source of truth for **why** things are as they are — history,
|
||||
rules, and the record of decisions including rejected ones. Edit it there, keep this file a
|
||||
thin shim.
|
||||
- Remember the AGENTS.md maintenance rule: update it (in English) in the same session whenever
|
||||
your changes affect anything it records.
|
||||
- Anything needing **Xcode or the Apple Developer Portal** goes in `APPLE_DEV_BACKLOG.md`, and
|
||||
runs are batched — never start one; that call is the owner's (Apple rule in AGENTS.md).
|
||||
|
||||
<!-- Claude Code does not read AGENTS.md natively; this import is the officially recommended
|
||||
pattern (code.claude.com/docs/en/memory, "AGENTS.md" section). -->
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# DataMetric360 — Companion App Architecture (design decided, not yet built)
|
||||
|
||||
**App name: `DataMetric360`** (decided 2026-08-10). Public hostname: a subdomain of
|
||||
**`datametric360.app`**,
|
||||
**`datametric360.de`**,
|
||||
a dedicated domain registered at all-inkl solely for this purpose (see §5.4 for why it must be a
|
||||
separate domain). Both the app name and the domain are deliberately neutral — they don't advertise
|
||||
"Audi", "car", or "GPS tracker" to anyone who sees the hostname or the app icon, which is a small but
|
||||
@@ -130,10 +130,46 @@ Entschlüsseln des binären Codec8-Protokolls. Das Gerät liefert die Daten dann
|
||||
- **Der zugehörige Transportweg ist MQTT**, nicht ein HTTP-Webhook: *GPRS Server Settings → MQTT*.
|
||||
Das war die wichtigste Überraschung — die erste Vermutung „JSON heißt HTTP-POST an einen
|
||||
HA-Webhook" ist falsch.
|
||||
- **Ein eigener Broker ist vorgesehen:** in den MQTT-Einstellungen werden schlicht IP und Port des
|
||||
Brokers eingetragen; Login und Passwort werden verwendet, wenn der Broker Anmeldung verlangt.
|
||||
- **Ein eigener Broker ist vorgesehen und bestätigt möglich:** *GPRS Server Settings → MQTT Settings →
|
||||
MQTT Client Type* bietet laut der Hilfe im Configurator (Screenshot vom Besitzer, 2026-08-11) vier
|
||||
Werte: `0 = AWS IoT Shadow`, `1 = AWS IoT Custom`, `2 = Azure IoT`, **`3 = Custom server`**. Die
|
||||
öffentliche Teltonika-Doku (Wiki, PDF-Guides) kennt nur die ersten drei — „Custom server" ist offenbar
|
||||
erst mit einer späteren Firmware/Configurator-Version dazugekommen und deshalb dort nicht beschrieben.
|
||||
Für „Custom server" werden IP/Domain und Port des eigenen Brokers (unter *Server Settings*, nicht im
|
||||
MQTT-Block selbst) eingetragen; Login und Passwort kommen zum Einsatz, wenn der Broker das verlangt.
|
||||
- **Praktisch bestätigt, mit einem offenen Warnsignal:** ein Teltonika-Community-Thread
|
||||
([FMC003 - Custom MQTT Broker?](https://community.teltonika.lt/t/fmc003-custom-mqtt-broker/14784),
|
||||
Juli 2025) zeigt einen Nutzer, der genau unser Setup (eigener Mosquitto auf einem VPS, eigene CA,
|
||||
Client-Zertifikat vom Gerät) über den Modus „AWS IoT Custom" nachgebaut hat — 24 Stunden stabil
|
||||
getestet, damals gab es „Custom server" noch nicht als eigene Option. Zwei Monate später berichtet
|
||||
ein anderer Nutzer **speziell für den FMM003**, dass bei ihm über MQTT keine Pakete am Server
|
||||
ankamen — nach Rückwechsel auf TCP lief es wieder. Keine Auflösung im Thread sichtbar.
|
||||
- **Konsequenz:** die MQTT-Anbindung gilt als grundsätzlich machbar, aber **nicht als sicher
|
||||
funktionierend, bevor sie mit dem echten Gerät getestet wurde** — TCP/Codec8 (der bisherige,
|
||||
bekannt funktionierende Weg über Traccar) bleibt der Rückfallplan, falls MQTT am FMM003 in der
|
||||
Praxis Probleme macht.
|
||||
- **„Custom server" beim Besitzer nicht auswählbar (2026-08-11):** im eigenen Configurator lässt
|
||||
sich Wert 3 nicht anklicken. Ein zweiter, unabhängiger Praxisbericht
|
||||
([mdworld.nl, FMC003 + eigener OpenRemote-Server](https://mdworld.nl/asset-tracking), Aug. 2025)
|
||||
bestätigt den gleichen Umweg wie der Community-Thread: **„AWS IoT Custom" wählen und dort auf den
|
||||
eigenen Server zeigen** — funktioniert nachweislich, unabhängig davon ob „Custom server" gerade
|
||||
wählbar ist oder nicht. Das ist damit der empfohlene Standardweg, nicht nur ein Fallback.
|
||||
- **Zertifikatsketten-Reihenfolge ist umgekehrt zur Konvention (wichtiger Stolperstein, aus
|
||||
demselben Praxisbericht):** Teltonika-Geräte erwarten die hochgeladene Zertifikatskette in
|
||||
**umgekehrter Reihenfolge** (Wurzelzertifikat zuerst, nicht zuletzt) — anders als das übliche
|
||||
`fullchain.pem`-Format (Server-Cert zuerst). Wird das ignoriert, schlägt der TLS-Handshake mit
|
||||
einem kryptischen Fehler fehl (`SSL routines::tlsv1 alert unknown ca`), der nicht offensichtlich
|
||||
auf die Reihenfolge hindeutet. **Vor dem Hochladen im Security-Tab die Kette umdrehen.**
|
||||
- **Topic-Namensschema (aus demselben Praxisbericht, als Orientierung):** z. B.
|
||||
`<name>/teltonika/%imei%/data` und `.../commands` — `%imei%` wird vom Gerät automatisch ersetzt.
|
||||
Exakte eigene Werte erst bei der Inbetriebnahme festlegen.
|
||||
- **TLS ist Pflicht, nicht optional:** ohne hochgeladene Zertifikate arbeitet MQTT nicht. Zertifikat,
|
||||
privater Schlüssel und Wurzelzertifikat werden im Configurator unter *Security* hinterlegt.
|
||||
- **Dateinamen-Endung ist strikt geprüft (2026-08-11, live am Configurator beobachtet):** hochgeladene
|
||||
Dateien müssen auf `.pem`, `.pem.crt` oder `.pem.key` enden, sonst Fehler „File format is not valid
|
||||
or not supported". Reiner Inhalt (PEM-Format) reicht nicht, die Endung muss stimmen. Schema folgt
|
||||
offenbar der AWS-IoT-Download-Konvention: Root-Zertifikat `*.pem`, Geräte-Zertifikat `*.pem.crt`,
|
||||
privater Schlüssel `*.pem.key`. Eigene CA-Dateien entsprechend umbenennen, nicht nur `.crt`/`.key`.
|
||||
- **Firmware-Abhängigkeit:** Codec JSON ist nicht in jeder Firmware enthalten (bei mehreren
|
||||
Modellen erst ab 03.28.00 dokumentiert). Der Besitzer bestätigt, dass es auf diesem Gerät
|
||||
verfügbar ist — vor der Inbetriebnahme trotzdem die Firmware-Version notieren.
|
||||
@@ -266,12 +302,13 @@ never reachable from the public internet. Only a narrow, purpose-built slice of
|
||||
tunnel from the HAOS host (the OptiPlex) to Cloudflare's edge; no router port ever opens. Confirmed
|
||||
capable of routing **multiple public hostnames to multiple local ports/services** — so it points at
|
||||
the reverse-proxy add-on's port below, not at HA's own port 8123.
|
||||
2. **Nginx Proxy Manager or Traefik add-on** — a path-scoped reverse proxy sitting between the tunnel
|
||||
and HA's internal API, on the Supervisor's internal Docker network. Configured with an **allowlist**
|
||||
of only the specific paths the companion app needs (e.g. `/api/states/sensor.audi_*`,
|
||||
`/api/services/pyscript/audi_dashboard_*` — exact path list still TBD, depends on the final entity/
|
||||
service names once the FMM003 mapping is built). Everything else — `/lovelace`, `/config`, `/auth`,
|
||||
the HA frontend itself — is blocked at this layer, never reaching the tunnel at all.
|
||||
2. **Nginx Proxy Manager add-on** (decided, 2026-08-28 — see §5 item 3) — a path-scoped reverse proxy
|
||||
sitting between the tunnel and HA's internal API, on the Supervisor's internal Docker network.
|
||||
Configured with an **allowlist** of only the specific paths the companion app needs (`/api/states/
|
||||
sensor.audi_dashboard_*`, `/api/services/audi_dashboard/*`, plus a handful more — the exact,
|
||||
current list lives in `homeassistant/REVERSE_PROXY.md`, not duplicated here since entity/service
|
||||
names can change and this doc would only go stale again). Everything else — `/lovelace`, `/config`,
|
||||
`/auth`, the HA frontend itself — is blocked at this layer, never reaching the tunnel at all.
|
||||
3. **Auth stays a second, independent layer:** every proxied call still requires the same Bearer LLAT
|
||||
HA's REST API already demands. Even a path that slipped through the allowlist would be useless
|
||||
without that token, and any individual LLAT can be revoked from the HA profile if the phone is ever
|
||||
@@ -297,28 +334,87 @@ surface *is* now internet-facing (even though HA itself never is) — accepted a
|
||||
Deliberately deferred until hardware arrives / the next architecture review — not blocking anything
|
||||
else in this document:
|
||||
|
||||
1. **Exact reverse-proxy path allowlist** — depends on the final pyscript entity/service names once the
|
||||
FMM003 combined-entity mapping is implemented; write these down here once decided.
|
||||
1. ✅ **ERLEDIGT (2026-08-28): reverse-proxy path allowlist defined and current.** Lives in
|
||||
`homeassistant/REVERSE_PROXY.md`, kept in sync with the real entity/service names
|
||||
(`sensor.audi_dashboard_*` / `audi_dashboard.<name>` since the native-integration conversion,
|
||||
`AGENTS.md` §H) rather than duplicated here. The step-by-step setup runbook that applies it in
|
||||
Nginx Proxy Manager is `homeassistant/INTERNET_ZUGRIFF_EINRICHTEN.md`. One correction worth
|
||||
recording: the allowlist originally (2026-08-11 planning) assumed the app itself would be served as
|
||||
a web build under `/local/dm360/` — that was never built. Distribution stayed **native/sideload
|
||||
only** (§1), so the allowlist instead needed the app's own OTA surface
|
||||
(`/audi_dashboard_static/app/*`, the `@capgo/capacitor-updater` bundle) rather than a served
|
||||
web app.
|
||||
2. **No-hardcoding mapping system** for the FMM003 combined entity — discussed in an earlier session,
|
||||
not yet written to a file or finalized in detail. **Kann erst nach der ersten echten
|
||||
MQTT-Nachricht fertig werden** (§2b): die Feldnamen im Codec-JSON sind noch unbekannt und werden
|
||||
mitgeschnitten, nicht geraten.
|
||||
2a. **Erreichbarkeit des MQTT-Brokers für das Fahrzeug** — Portfreigabe 8883 gegen VPS-Broker mit
|
||||
Mosquitto-Bridge, siehe §2b. Betrifft nur den Weg Fahrzeug→Zuhause und ist unabhängig von der
|
||||
Anbindung der App (§4, dort bleibt es bei Cloudflare Tunnel).
|
||||
2b. **TLS-Zertifikate für Mosquitto und das Gerät** — bei der Inbetriebnahme zu erzeugen; ohne sie
|
||||
verweigert der FMM003 die MQTT-Verbindung.
|
||||
3. **Reverse-proxy add-on: Nginx Proxy Manager vs. Traefik** — both viable (§4), pick deferred to the
|
||||
next audit pass. Doesn't depend on the FMM003 — could be set up and tested against HA's existing API
|
||||
before the hardware arrives, if worth doing ahead of time.
|
||||
4. **Cloudflare Tunnel domain** — ✅ **ERLEDIGT: `datametric360.app` ist bei all-inkl registriert**
|
||||
(Stand 2026-08-11). Eigens dafür angelegt, ohne Webseite und ohne Postfach darauf — genau so, wie
|
||||
es die unten stehende Einschränkung verlangt. Vorgesehene Adresse der App später:
|
||||
`https://datametric360.app`.
|
||||
2a. ✅ **ERLEDIGT (Entscheidung, 2026-08-11): Portfreigabe 8883**, nicht VPS-Bridge. Begründung: ein
|
||||
Broker mit TLS-Zwang und Pflicht-Client-Zertifikat lässt vor dem eigentlichen Login niemanden
|
||||
auch nur ansetzen — der Mehraufwand einer dauerhaften VPS-Miete plus zweitem Mosquitto plus
|
||||
Bridge-Verbindung stand in keinem Verhältnis zum zusätzlichen Schutz für ein einzelnes
|
||||
Privatfahrzeug. Betrifft nur den Weg Fahrzeug→Zuhause und ist unabhängig von der Anbindung der
|
||||
App (§4, dort bleibt es bei Cloudflare Tunnel — kein Widerspruch, siehe dort die Begründung, warum
|
||||
ein eng begrenzter, zertifikatsgesicherter Port etwas anderes ist als HA selbst offenzulegen).
|
||||
**Umsetzungsstand (2026-08-11):** DuckDNS-Hostname `datametric360.duckdns.org` angelegt und als
|
||||
Add-on in HA eingerichtet, aktualisiert zuverlässig die öffentliche IP — das ist der für den
|
||||
FMM003-Pfad tatsächlich entscheidende Teil. Router bestätigt **Dual-Stack** (kein DS-Lite), die
|
||||
Portfreigabe 8883 am Speedport Smart 4 Plus ist eingerichtet, FMM003 sendet bestätigt Daten.
|
||||
|
||||
`.app` steht auf der HSTS-Preload-Liste: Browser erzwingen dort HTTPS bedingungslos. Das passt zum
|
||||
Cloudflare Tunnel (immer HTTPS) und schließt eine versehentliche Klartextverbindung von vornherein
|
||||
aus.
|
||||
**Let's-Encrypt-Zertifikat für die HA-Oberfläche selbst — ✅ ERLEDIGT (2026-08-11), nach
|
||||
ausführlicher Fehlersuche.** Der `deploy_challenge`-Hook (DNS-01, TXT-Eintrag via DuckDNS-API)
|
||||
scheiterte zunächst wiederholt mit `timeout 120s` beim Warten auf den eigenen TXT-Eintrag, obwohl
|
||||
DuckDNS den Eintrag nachweislich korrekt setzte (bestätigt per `nslookup` gegen `8.8.8.8` vom PC
|
||||
*und* per `dig` aus der HA-eigenen SSH-Konsole gegen den internen Supervisor-Resolver). Der DNS-
|
||||
Server selbst war am Ende kein Faktor (Erfolg trat auch mit dem Speedport als DNS-Server ein) —
|
||||
**tatsächliche Ursache war ein fehlerhafter `aliases`-Eintrag** in der DuckDNS-Add-on-Konfiguration
|
||||
(`alias: datametric360` ohne `.duckdns.org`, überflüssig und falsch, da keine externe Domain per
|
||||
CNAME verwendet wird — `aliases` ist nur für diesen Fall gedacht) zusammen mit `accept_terms:
|
||||
false`. Nach Entfernen von `aliases` und Setzen von `accept_terms: true` lief die Zertifikatsanfrage
|
||||
beim nächsten Versuch sofort erfolgreich durch (`Challenge is valid! ... Creating fullchain.pem...
|
||||
Done!`), Zertifikat gültig bis 2026-11-09.
|
||||
|
||||
Anschließend `configuration.yaml`'s alter `http:`-Block (der HA-Start blockiert hatte, solange die
|
||||
Zertifikatsdateien nicht existierten) entfernt — HA migriert SSL-Zertifikatspfad/-Schlüssel und die
|
||||
interne/externe URL inzwischen in die Oberfläche (Einstellungen → System → Netzwerk). **Eigenheit
|
||||
dabei:** eine geänderte Netzwerkkonfiguration muss innerhalb von 5 Minuten über einen Dialog in der
|
||||
Oberfläche bestätigt werden, sonst macht HA sie automatisch rückgängig und startet mit dem vorigen
|
||||
Stand neu (`Pending HTTP config was not confirmed within 0:05:00` im Log) — beim ersten Versuch
|
||||
deshalb ungewollt zurückgerollt, beim zweiten Versuch bewusst sofort bestätigt, seitdem stabil.
|
||||
Externe URL bewusst leer gelassen (Port 8123 ist nicht freigegeben, HA bleibt nicht öffentlich
|
||||
erreichbar); interne URL auf `https://192.168.2.216:8123` gesetzt — das Zertifikat ist auf den
|
||||
Hostnamen ausgestellt, nicht auf die IP, der Browser zeigt deshalb bei IP-Zugriff weiterhin eine
|
||||
Namensabgleich-Warnung (Verbindung bleibt trotzdem verschlüsselt); ein lokaler DNS-Eintrag, der
|
||||
`datametric360.duckdns.org` intern auf die lokale IP auflöst, wäre die sauberere, aber optionale
|
||||
Nachbesserung.
|
||||
|
||||
Nebenbei geklärt: ein Vorschlag, stattdessen komplett auf Cloudflare zu setzen, wurde geprüft und
|
||||
**ist keine Alternative** — Cloudflare Tunnel kann kein rohes MQTT/TCP transportieren (nur HTTP),
|
||||
der FMM003 kennt außerdem kein Tunnel-Konzept, nur IP/Domain+Port. Bleibt bei der klaren Trennung:
|
||||
Cloudflare Tunnel für die App (§4), Portfreigabe+DuckDNS nur für die Fahrzeug-MQTT-Verbindung.
|
||||
2b. **TLS-Zertifikate für Mosquitto und das Gerät** — ✅ **ERLEDIGT (2026-08-11):** eigene private CA
|
||||
(10 Jahre gültig) erzeugt, Server-Zertifikat (CN `datametric360.duckdns.org`) und Client-Zertifikat
|
||||
für das Gerät signiert, beide gegen die CA verifiziert. Mosquitto ist mit `certfile`/`keyfile`/
|
||||
`cafile`/`require_certificate: true` konfiguriert. **Noch offen:** die drei Geräte-Dateien (Root,
|
||||
Client-Zertifikat, privater Schlüssel) müssen noch in den FMM003-Configurator hochgeladen werden —
|
||||
dabei auf die Dateinamen-Endung achten (siehe Anmerkung oben bei „Dateinamen-Endung ist strikt
|
||||
geprüft").
|
||||
3. ✅ **ERLEDIGT (2026-08-28): Nginx Proxy Manager**, not Traefik. Both remained viable per §4; NPM was
|
||||
picked and is what `homeassistant/REVERSE_PROXY.md` and `INTERNET_ZUGRIFF_EINRICHTEN.md` are written
|
||||
against. Setup itself (Cloudflare account, nameserver switch, add-on installation) is still the
|
||||
owner's own action to perform — see the runbook for the exact steps.
|
||||
4. **Cloudflare Tunnel domain** — ✅ **ERLEDIGT: `datametric360.de` ist bei all-inkl registriert**
|
||||
(ursprünglich `.app`, seither auf `.de` umgestellt — Stand 2026-08-28). Eigens dafür angelegt, ohne
|
||||
Webseite und ohne Postfach darauf — genau so, wie es die unten stehende Einschränkung verlangt.
|
||||
Vorgesehene Adresse der App später: `https://datametric360.de`.
|
||||
|
||||
**Korrektur 2026-08-28:** die ursprüngliche `.app`-Wahl hatte einen zusätzlichen Vorteil, der mit dem
|
||||
Wechsel auf `.de` entfällt — `.app` steht auf Chromes HSTS-Preload-Liste, `.de` nicht, der Browser
|
||||
erzwingt dort also nicht von sich aus HTTPS ohne vorherigen Kontakt zur Domain. Das ändert nichts an
|
||||
der Sicherheit dieses Aufbaus: Cloudflare Tunnel liefert ohnehin ausschließlich HTTPS aus, und Nginx
|
||||
Proxy Manager kann zusätzlich "Force SSL" erzwingen (siehe `INTERNET_ZUGRIFF_EINRICHTEN.md` Schritt
|
||||
4) - nur der zusätzliche, browserseitig *vorab* erzwungene Schutz vor dem allerersten Verbindungs-
|
||||
aufbau (bevor die App überhaupt einmal erfolgreich verbunden war) entfällt. Für ein sideload-only
|
||||
installiertes, nicht öffentlich beworbenes Gerät eine vernachlässigbare Einbuße.
|
||||
|
||||
**Noch offen, und der eigentliche Knackpunkt:** die Nameserver der Domain müssen bei all-inkl auf
|
||||
Cloudflare umgestellt werden („Full setup") — erst dann kann der Tunnel einen Hostnamen darunter
|
||||
@@ -326,10 +422,12 @@ else in this document:
|
||||
setup"), gibt es nur im Business-Tarif für 200 $/Monat. Weil auf dieser Domain nichts liegt außer
|
||||
diesem Vorhaben, ist die Umstellung folgenlos.
|
||||
|
||||
Noch zu entscheiden (Kleinigkeit, erst bei der Einrichtung): ob die App unter der Domain selbst
|
||||
liegt und die abgesicherte Schnittstelle unter einer Unteradresse (etwa `api.datametric360.app`),
|
||||
oder umgekehrt. Beides funktioniert; ein getrennter Hostname für die Schnittstelle macht die
|
||||
Pfad-Freigabeliste im Reverse Proxy übersichtlicher.
|
||||
✅ **ERLEDIGT (2026-08-28): ein einziger Hostname genügt**, kein Split nötig. Der ursprüngliche
|
||||
Gedanke (App-Domain vs. API-Subdomain) setzte voraus, dass die App selbst als Web-Build unter einer
|
||||
eigenen Adresse ausgeliefert würde — das wurde nie gebaut (Distribution blieb nativ/sideload, §1).
|
||||
Es gibt nur eine Adresse, die überhaupt gebraucht wird: `https://datametric360.de`, die die App
|
||||
sowohl für API-Aufrufe als auch für ihr eigenes OTA-Bündel verwendet (siehe §5 Punkt 1 oben,
|
||||
`homeassistant/REVERSE_PROXY.md`).
|
||||
|
||||
Die Einschränkung, die zur eigenen Domain geführt hat:
|
||||
|
||||
@@ -340,7 +438,7 @@ else in this document:
|
||||
($200/month)**, i.e. not realistic here.
|
||||
|
||||
**Therefore: never point this at an existing all-inkl domain carrying live websites or email** —
|
||||
hence the dedicated `datametric360.app`. Nothing about the owner's existing domains/mail is touched.
|
||||
hence the dedicated `datametric360.de`. Nothing about the owner's existing domains/mail is touched.
|
||||
|
||||
Note the all-inkl "Neue Domain anlegen" dialog asks for a target (Webspace / Redirect /
|
||||
Webbaukasten) — that choice is irrelevant here, since DNS gets delegated to Cloudflare afterwards
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# Design Audit — 2026-08-13 (Grossbildschirm-Layout)
|
||||
|
||||
Auslöser: zwei konkrete User-Reports ("Einstellungen-Knopf sitzt am Bildschirmrand, obwohl
|
||||
die App-Spalte schon vorher endet" und "die Ringe als Einstellungen-Knopf sind zu klein").
|
||||
Beide sind bestätigt, behoben und live im `audi_ha_test`-Container verifiziert (Desktop
|
||||
1920px + Mobil 375px, Chrome-Konsole fehlerfrei bis auf die vorbestehenden, unbezogenen
|
||||
Service-Worker-/Platzhalterbild-404-Meldungen). Im Zuge der Fehlersuche wurde ein dritter,
|
||||
verwandter Fehler im selben Muster gefunden und mitbehoben.
|
||||
|
||||
Geprüft wurden Übersicht, Mein Audi, Fahrten, Statistik, Tanken und Einstellungen (inkl.
|
||||
Setup-Popup) bei 375px und 1920px. Die drei unten dokumentierten Befunde sind die einzigen
|
||||
gefundenen Abweichungen; der Rest folgt konsistent den bestehenden Tokens (`--r-tile`,
|
||||
Farbrollen aus dem iOS-Overlay, `.aktion`/`.switch`-Semantik).
|
||||
|
||||
## Befund 1 — Zahnrad/Kopfzeile sass bei breiten Fenstern am Fensterrand statt am Inhaltsrand
|
||||
|
||||
**Root Cause:** `.topbar` (enthält den Einstellungen-Knopf `.profilbtn`) liegt im
|
||||
Grossbildschirm-Raster (`@container (min-width:860px)` in `audi-dashboard-ios.css`) in
|
||||
Spalte 2 (`grid-column:2`), derselben Spalte wie `main#view`. `main#view` hat dort ein
|
||||
`max-width:860px`, `.topbar` hatte keins - die Spalte selbst ist aber `minmax(0,1fr)`, füllt
|
||||
also die volle restliche Fensterbreite. `.head{flex:1}` in der Kopfzeile hat den Rest der
|
||||
Zeile aufgefüllt und `.profilbtn` bis an den rechten Rand der vollen Spalte geschoben, weit
|
||||
hinter das Ende des sichtbaren Inhalts.
|
||||
|
||||
**Beleg (1920px Fenster):** `main#view` endete bei `x=1380`, `.topbar` ging bis `x=1920`,
|
||||
`.profilbtn` sass bei `x=1832-1876` - eine Lücke von über 450px zwischen Inhaltsende und
|
||||
Knopf.
|
||||
|
||||
**Fix:** `.topbar`, `.phone:has(.back.on) .topbar` und `.ptr` bekommen im
|
||||
860px-Container-Query dasselbe `max-width:860px` wie `main#view`. Nach dem Fix enden
|
||||
`.topbar` und `main#view` an derselben Kante (`x=1380`), der Knopf sitzt bei `x=1292-1336`,
|
||||
sichtbar über dem Inhalt.
|
||||
|
||||
**Datei:** `homeassistant/www/audi-dashboard-ios.css`
|
||||
|
||||
## Befund 2 — Audi-Ringe als einziger Einstellungen-Zugang in Telefonbreite zu klein
|
||||
|
||||
**Root Cause:** In Telefonbreite blendet die iOS-Auflage das Zahnrad-Icon (`.profilbtn`) aus
|
||||
- die Audi-Ringe (`#marke`/`.rings`) sind dort laut eigenem Code-Kommentar "der einzige
|
||||
Zugang zu den Einstellungen" und seit einer früheren Session bewusst ein echter,
|
||||
tastaturbedienbarer Knopf. Die Knopfgrösse war mit 42×24px aber deutlich kleiner als jeder
|
||||
andere Symbol-Knopf in derselben Kopfzeile (`.themebtn`/`.profilbtn`/`.back` sind alle
|
||||
44×44px) und lag unter der üblichen 44px-Mindestgrösse für Tippflächen.
|
||||
|
||||
**Fix:** `.rings` von 42×24px auf 58×32px vergrössert (Seitenverhältnis beibehalten,
|
||||
`.rings svg{width:58px}`). Funktion (Klick navigiert zu Einstellungen) unverändert getestet.
|
||||
|
||||
**Datei:** `homeassistant/www/audi-dashboard.css`
|
||||
|
||||
## Befund 3 — Popups/Sheets auf breiten Bildschirmen fast bildschirmbreit (gleiches Muster wie Befund 1)
|
||||
|
||||
Beim Nachprüfen von Befund 1 fiel derselbe Fehler bei vier weiteren Elementen auf: sie sind
|
||||
`position:absolute` mit `left`/`right` relativ zu `.phone` (der vollen Rasterbreite,
|
||||
Seitenspalte inklusive) statt zur schmaleren Inhaltsspalte, ohne eigenes `max-width` für
|
||||
grosse Bildschirme:
|
||||
|
||||
- `.setup-popup` (Sensor-Zuordnung) - **bestätigt live:** 1644px breit bei 1920px Fenster
|
||||
(nur 10px Rand auf jeder Seite), praktisch bildschirmfüllend statt eines ruhigen Dialogs.
|
||||
- `.sdpopup` (SmartDeal-Aktivierung)
|
||||
- `.sheet` (Action-Sheet, ersetzt confirm()/alert() beim Löschen)
|
||||
- `.standortmenu` (Standort-Bottom-Sheet auf der Kartenansicht)
|
||||
|
||||
**Fix:** Im selben 860px-Container-Query `max-width:560px` (Popups/Sheet) bzw. `max-width:
|
||||
640px` (Standortmenü, mehr Inhalt) plus `margin-left/right:auto` ergänzt - zentriert die
|
||||
Elemente innerhalb ihres bestehenden `left`/`right`-Abstands, ohne die
|
||||
Transform-basierte Einblend-Animation dieser Elemente anzufassen (eine
|
||||
`transform:translateX(-50%)`-Zentrierung hätte mit der `sheet-rein`-Keyframe-Animation
|
||||
kollidiert, die am Ende selbst `transform:none` setzt). `border-radius` bleibt jeweils
|
||||
unverändert (`.standortmenu` behält oben abgerundete/unten eckige Kanten, passend zur
|
||||
weiterhin am unteren Fensterrand andockenden Bauweise).
|
||||
|
||||
**Beleg nach Fix (Setup-Popup, 1920px Fenster):** 560px breit, zentriert bei `x=808-1368`.
|
||||
|
||||
**Datei:** `homeassistant/www/audi-dashboard-ios.css`
|
||||
|
||||
## Nicht verändert (bewusst ausserhalb dieses Audits)
|
||||
|
||||
- `.bildmenu` (Kontextmenü bei Fahrzeugbildern) ist bereits `min-width:160px` und wächst
|
||||
nicht mit der Fensterbreite - kein Fehler.
|
||||
- Zentrierung der Popups erfolgt relativ zur vollen `.phone`-Breite (Seitenspalte
|
||||
inklusive), nicht exakt zur Mitte der 860px-Inhaltsspalte - bei 1920px ein Versatz von
|
||||
ca. 138px nach rechts gegenüber echter Inhaltsmitte. Sichtbar besser als der vorige
|
||||
Zustand (praktisch volle Fensterbreite), aber für pixelgenaue Zentrierung müssten die
|
||||
Popups im Markup in die Grid-Spalte 2 verschoben werden statt als Geschwister von
|
||||
`main#view` direkt in `.phone` zu liegen - eine grössere strukturelle Änderung, hier
|
||||
bewusst zurückgestellt.
|
||||
|
||||
## Runde 2 — sechs weitere User-Findings
|
||||
|
||||
Vier bestätigt, ursächlich geklärt und behoben; zwei trotz gezielter Suche (Computed-Style-
|
||||
Vergleich, 2.5x-Zoom per temporärem `transform:scale()` auf das Live-DOM, Tag- und
|
||||
Nacht-Theme) nicht reproduzierbar - siehe unten.
|
||||
|
||||
### Befund 4 — Kopfzeile (Übersicht/Fahrten/...) auf breiten Bildschirmen 30px zu weit rechts
|
||||
|
||||
**Root Cause:** `.back` (Zurück-Pfeil) ist auch inaktiv (`opacity:0`, kein `.on`) weiterhin
|
||||
ein 44px breites Flex-Element in `.topbar` plus `gap`. Auf Wurzelseiten ohne Zurück-Pfeil
|
||||
(Übersicht, Fahrten, ...) schiebt dieser unsichtbare Platzhalter `.head`/`.title` sichtbar
|
||||
weiter nach rechts als `main#view` darunter beginnt.
|
||||
|
||||
**Beleg:** `.title` links bei `x=594`, die erste Kachel in `main#view` links bei `x=564` -
|
||||
30px Versatz.
|
||||
|
||||
**Fix:** `.back:not(.on){width:0;min-width:0;margin:0;overflow:hidden}` im
|
||||
860px-Container-Query. Nur für Wurzelseiten relevant; auf Detailseiten (`.back.on`, z. B.
|
||||
Einzelfahrt) bleibt der Knopf unverändert 44px breit und funktionsfähig - live getestet
|
||||
(Klick navigiert zurück zur Liste).
|
||||
|
||||
**Beleg nach Fix:** Versatz auf 4px reduziert (der verbleibende `gap` zum nächsten
|
||||
Flex-Element, nicht mehr wahrnehmbar).
|
||||
|
||||
**Datei:** `homeassistant/www/audi-dashboard-ios.css`
|
||||
|
||||
### Befund 5 — Graue Pfeile auf "Mein Audi" (Fahrzeug/Service/Versicherung/Reifen) überlappen Text
|
||||
|
||||
**Root Cause:** `.tilebtn .go{top:50%;margin-top:-5px}` zentriert den Kachel-Chevron
|
||||
vertikal auf die **gesamte** Kachelhöhe. Diese fünf Kacheln sind aber keine Einzeiler,
|
||||
sondern `<span class="label">Titel</span>` + Chevron + eine ganze `dl.rows`-Liste darunter -
|
||||
die 50%-Mitte der gesamten Kachel liegt damit mitten in der Zeilenliste statt neben dem
|
||||
Titel, und überlappt besonders bei mehrzeilig umgebrochenen Werten (z. B. "2.9 TFSI quattro
|
||||
· 331 kW · 450 PS") sichtbar den Text.
|
||||
|
||||
**Fix:** `top:50%;margin-top:-5px` aus der iOS-Auflage entfernt - fällt zurück auf
|
||||
`top:var(--sp-5)` aus der Basis, die den Chevron wieder neben den Titel oben in der Kachel
|
||||
setzt (dafür existiert dort bereits extra Padding: `.tile:has(> .go) > .label:first-child
|
||||
{padding-right:44px}`).
|
||||
|
||||
**Betroffen:** alle 5 `.go`-Kacheln (Fahrzeug, Service, Versicherung/Steuer, Reifen,
|
||||
Schutzbrief Mobilität) - identisches Markup-Muster überall.
|
||||
|
||||
**Datei:** `homeassistant/www/audi-dashboard-ios.css`
|
||||
|
||||
### Befund 6 — "10.000 km" / "1 Jahr" (Ölwechsel-Intervall) sehen nicht editierbar aus
|
||||
|
||||
**Root Cause:** Beide Werte sind echte `<select>`-Dropdowns, aber die iOS-Auflage entfernt
|
||||
Rahmen und Fläche komplett (`background:transparent;border:0`) und färbt den Wert in
|
||||
`--fg2` - demselben gedämpften Grau wie jeder andere Fliesstext. Kombiniert mit
|
||||
`appearance:none` (entfernt zusätzlich den nativen Auswahlpfeil des Browsers, base-CSS) gibt
|
||||
es keinerlei visuellen Hinweis, dass hier eine Auswahl möglich ist.
|
||||
|
||||
**Fix:** `.feld select{color:var(--ios-tint)}` ergänzt - der Wert erscheint jetzt in der
|
||||
Marken-Akzentfarbe (Rot), dem iOS-üblichen Signal "hier gibt es eine Auswahl" bei
|
||||
Picker-artigen Feldern (anders als bei freien Texteingaben, die bewusst unverändert bleiben).
|
||||
|
||||
**Datei:** `homeassistant/www/audi-dashboard-ios.css`
|
||||
|
||||
### Befund 7 — Auf/Zu-Pfeile bei Gruppenkopfzeilen (z. B. "2026", "August" in Fahrten) sehen wie Häkchen aus
|
||||
|
||||
**Root Cause:** `.mark` baut den Chevron aus der klassischen "zwei Kanten + 45°-Drehung"-
|
||||
CSS-Technik (`border-right`+`border-bottom`, `transform:rotate(-45deg/45deg)`). Diese
|
||||
Technik braucht zwingend ein **Quadrat** für eine saubere Pfeilform - `.mark` war aber
|
||||
6×10px (für die SVG-Chevrons `.chev`/`.go` gedacht, die eine eigene, absichtlich
|
||||
nicht-quadratische Pfad-Form nutzen). Die ungleich langen Kantenabschnitte (10px vs. 6px)
|
||||
ergeben verdreht eine Form, die eher wie ein Häkchen als ein Pfeil aussieht - besonders im
|
||||
per Default aufgeklappten Zustand (`rotate(45deg)`), in dem "2026"/"August" standardmässig
|
||||
starten.
|
||||
|
||||
**Fix:** `.mark` auf 8×8px quadratisch gesetzt.
|
||||
|
||||
**Datei:** `homeassistant/www/audi-dashboard.css`
|
||||
|
||||
### Nicht reproduziert — Befund A: "Alle Menü-Icons haben eine graue Box"
|
||||
|
||||
Programmatisch geprüft (`.tab`, `.tabpille`, `.tab svg`: `background`, `border`,
|
||||
`box-shadow`, `outline` für alle 5 Tabs sowohl im Seitenmenü (Desktop) als auch in der
|
||||
Tableiste unten (375px)): nur der **aktive** Tab hat eine (sehr dezente, 7% Deckkraft)
|
||||
graue Füllung - `.tab.on .tabpille{background:var(--tile-2)}`/`.tab.on{background:
|
||||
var(--ios-fill)}`, exakt wie beabsichtigt. Die vier inaktiven Tabs sind computed
|
||||
`rgba(0,0,0,0)` (vollständig transparent) auf allen geprüften Ebenen. Falls auf dem
|
||||
tatsächlichen Gerät etwas anderes zu sehen ist (z. B. alle 5 Icons mit sichtbarer Box),
|
||||
bräuchte es einen Screenshot vom echten Gerät/Browser, um die Abweichung von diesem
|
||||
Testcontainer einzugrenzen.
|
||||
|
||||
### Nicht reproduziert — Befund B: "Roter Teilrahmen rechts an jeder Zeile" (Fahrten/Tanken)
|
||||
|
||||
Geprüft: `.swipe-content{background:var(--tile-deckend)}` (deckend, #171B21 Nacht /
|
||||
#FFFFFF Tag) deckt den dahinterliegenden roten Löschen-Button (`.swipe-delete`,
|
||||
`rgb(255,69,58)`) vollständig ab - beide Boxen exakt deckungsgleich (`x`/`width`/`right`
|
||||
identisch bis auf Subpixel), kein CSS-seitiger Spalt gefunden. Bei 2.5x-Zoom (temporärer
|
||||
`transform:scale()` auf das Live-DOM, nicht deploybar, nur zur Inspektion) war am rechten
|
||||
Rand in Tag- und Nacht-Theme kein Rot sichtbar. Möglich, dass dies nur unter bestimmten
|
||||
Bedingungen auftritt (Swipe-Geste mitten in der Transition, ein bestimmter Zoom-/DPI-Stand
|
||||
auf dem echten Gerät) - ein Screenshot oder eine genauere Beschreibung, wann genau es
|
||||
auftritt (beim Laden, nach einer Wischgeste, permanent?), würde helfen.
|
||||
|
||||
## Deployment
|
||||
|
||||
Alle sieben Fixes deployed und verifiziert im `audi_ha_test`-Docker-Container
|
||||
(`audi-dashboard-version.json` → `1786744000`), nach `homeassistant/installationspaket/`
|
||||
synchronisiert. Konsole fehlerfrei bis auf die vorbestehenden, unbezogenen
|
||||
Service-Worker-/Platzhalterbild-404-Meldungen.
|
||||
@@ -11,6 +11,16 @@ system attached. Then iterate screen by screen. See the step-by-step in the chat
|
||||
Entwirf eine App namens "DataMetric360" — eine private Fahrzeug-App für ein einzelnes Auto.
|
||||
Nutze ausschließlich die Komponenten aus dem angehängten Design-System (Audi Dashboard UI).
|
||||
|
||||
WICHTIG — Marken-Assets:
|
||||
Anders als das angehängte Design-System (das bewusst neutral gehalten ist) darf diese App echte
|
||||
Audi-Marken-Assets verwenden: die vier Ringe, die Audi-Type-Schrift, die Modell-Typenschilder
|
||||
(RS 4 Avant competition). Kein generischer Ersatz nötig.
|
||||
|
||||
WICHTIG — kein Fernsteuerungs-Charakter:
|
||||
Diese App ist eine reine Anzeige- und Erfassungs-App. Keine Bedienelemente zum Verriegeln,
|
||||
Entriegeln, Starten oder sonstigen Fernsteuern des Fahrzeugs entwerfen — auch nicht als Mockup
|
||||
oder Platzhalter, der das suggeriert.
|
||||
|
||||
WICHTIG — Grundlagen:
|
||||
- Dunkles Theme ("Nacht") ist Standard, helles Theme ("Tag") existiert als Umschaltung.
|
||||
- Keine Schatten, keine Verläufe. Flache Kacheln, Pill-Buttons, Haarlinien als Trenner.
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
# Design-Review des `main`-Branch — Abgleich gegen die Audi-CI-Vorgaben des Repos
|
||||
|
||||
**Datum:** 2026-08-13 · **Gegenstand:** `origin/main` @ `2a3c4f2` („Zugangswege zum privaten
|
||||
Repository dokumentieren und trennen") · **Nur Befunde, keine Änderungen.**
|
||||
|
||||
> Hinweis vorab: Das **lokale** `main` hängt 28 Commits hinter `origin/main`. Dieses Review
|
||||
> basiert auf `origin/main` — der Stand mit FMM003-Umstellung, Setup-Menü, Standort-Feature und
|
||||
> der scharf geschalteten iOS-/Großbildschirm-Auflage.
|
||||
|
||||
## Methode
|
||||
|
||||
1. **Vorgaben-Checkliste** aus den Repo-Dokumenten destilliert (`bauauftrag.md`,
|
||||
`SPECIFICATION.md`, `AUDIT_2026-08-10.md`, `DESIGN_BRIEF_DATAMETRIC360.md`, `AGENTS.md`,
|
||||
`design-system/`-Tokens) — Farben, Typografie, Layout, Marke, Formate, Interaktion, A11y.
|
||||
2. **Code-Review** aller UI-Schichten auf `main` gegen diese Checkliste:
|
||||
`homeassistant/www/` (Panel-JS + beide CSS), `design/`-Export, `design-system/`,
|
||||
`companion-app/`.
|
||||
3. **Browser-Test mit Screenshots:** Das Panel wurde in einem Harness mit Mock-`hass`-Objekt
|
||||
und realistischen Beispieldaten (Beispielprofil, 5 Fahrten, 3 Tankvorgänge, Fahrzeugstatus,
|
||||
Setup-Katalog) headless in Chromium gerendert — iPhone-Viewport (390×844, hell + dunkel) und
|
||||
Desktop (1440×900, Großbildlayout ≥860 px), alle 5 Tabs plus 10 Unterseiten, Einstellungen,
|
||||
Einrichten-Formular und Setup-Popup. ~40 Screenshots, Pfade im Annex.
|
||||
|
||||
**Grenzen:** Kein echtes Home Assistant, kein echtes iOS-Gerät. Native Steuerelemente
|
||||
(`<input type="date">`) und Leaflet-Verhalten können im Harness abweichen — solche Befunde sind
|
||||
unten ausdrücklich als „am Gerät verifizieren" markiert.
|
||||
|
||||
---
|
||||
|
||||
## Kernaussagen (TL;DR)
|
||||
|
||||
1. **Die iOS-Auflage (`audi-dashboard-ios.css`) ist der größte offene CI-Konflikt.** Sie ersetzt
|
||||
die dokumentierte Audi-Palette durch iOS-Systemfarben, führt Schatten und Blur ein, ändert den
|
||||
Kachelradius auf 16 px und macht Rot zur Flächenfüllung — alles Verstöße gegen in mehreren
|
||||
Dokumenten wiederholte, bindende Regeln, und **nirgends als Entscheidung dokumentiert**
|
||||
(`design/README.md` sagt selbst, das sei „noch zu klären", faktisch ist es seit 2026-08-11
|
||||
produktiv).
|
||||
2. **Die Rot-Semantik ist dadurch invertiert.** `ios.css` füllt jeden `.aktion`-Knopf rot. Sichtbare
|
||||
Folge (Screenshots): im Setup-Popup ist **„Abbrechen" rot gefüllt und „Speichern" schwarz**;
|
||||
„Einrichten", „Setup — Sensoren zuordnen", „In den Kalender übernehmen" und die
|
||||
Notruf-/Telefonnummern sind großflächig rote Balken. Der Audit-Befund A7 („Rot bleibt Akzent
|
||||
und Destruktiv") ist damit rückgebaut.
|
||||
3. **`design-system/` (Basis für DataMetric360) trägt den Vor-Audit-Stand:** kontrastschwaches
|
||||
`--fg3 #657081` (WCAG-AA-Fail, im Panel längst korrigiert), Versalien-Sperrschrift in 6+
|
||||
Komponenten, **kein einziger Fokus-Stil in der ganzen Bibliothek**, Formularfelder 13,5 px
|
||||
(iOS-Zoom-Falle), IconButton 34 px. Wer die App darauf aufbaut, erbt behobene Fehler zurück.
|
||||
4. **Vier echte Darstellungs-/Zustandsfehler im Panel, im Browser nachgewiesen** (unten F1–F4),
|
||||
darunter gestreckte Schalter mit widersprüchlicher Zustandsanzeige (Ursache identifiziert:
|
||||
`.feld label { flex: 1 1 auto }` trifft auch `label.switch`).
|
||||
5. **Positiv:** Schriftgewichts-Regel (nur 300/400) wird zu 100 % gehalten, de-DE-Formate sind
|
||||
praktisch lückenlos, `design-system/` ist nachweislich **marken-frei** (Lizenzregel bestanden),
|
||||
die Marke steht auf jedem Layout genau einmal, und die neuen main-Features sind bei
|
||||
aria-Attributen überdurchschnittlich sorgfältig.
|
||||
|
||||
---
|
||||
|
||||
## A. Grundsatzkonflikt: iOS-Auflage vs. Audi-CI (hoch)
|
||||
|
||||
Verbindlich laut `bauauftrag.md` §Design, `SPECIFICATION.md` §Design und Audit: Palette als
|
||||
Rollen (Nacht `#161b23`/`#1f2733`, Tag `#FFFFFF`/`#f2f2f2`), Signalfarben `#15da15/#ffaa00/#fd2c4e`,
|
||||
Kachelradius 20 px, **keine Schatten, keine Verläufe** (einzige Ausnahme Fahrbahn/`.szene`),
|
||||
Rot `#F50537` nur als Akzent. Die seit 2026-08-11 aktive `audi-dashboard-ios.css` bricht das
|
||||
systematisch:
|
||||
|
||||
| Regel | ios.css-Ist | Fundstelle |
|
||||
|---|---|---|
|
||||
| Nacht-Canvas `#161b23` | `#0C1014` | `audi-dashboard-ios.css:21-41` |
|
||||
| Tag: Canvas weiß, Kachel `#f2f2f2` | **invertiert**: Canvas `#F2F2F7`, Kachel `#FFFFFF` | ebd. |
|
||||
| Audi-Signalfarben | iOS-Systemfarben `#30D158/#FFD60A/#FF453A` bzw. `#34C759/#FF9F0A/#FF3B30` | ebd. |
|
||||
| Kachelradius 20 px (`--r-tile`) | `--r-tile: 16px` | `audi-dashboard-ios.css:16` |
|
||||
| Keine Schatten/Blur | `box-shadow` auf Segmented Control, Switch-Knopf, Popups; `backdrop-filter: blur()` auf Tabbar/Popups | `:116, :135, :156-166` |
|
||||
| Rot nie als Fläche | `.aktion{background:var(--ios-tint)}`, roter Schalter, `.tab.on` rot hinterlegt | `:118-136, :221` |
|
||||
| Safe-Area-Insets (Audit B1) | ersetzt durch Festwerte `padding:56px` / `26px` | `:65, :163` |
|
||||
| Trefferfläche ≥44 px (Audit B6) | `.back` auf **34×34 px** gedrückt | `:69` |
|
||||
|
||||
Dazu kommt: `.setup-popup` und `.standortmenu` schreiben `border-radius: 20px` als **Literal**
|
||||
fest (`audi-dashboard.css:895, :744`) — mit dem ios-Token 16 px sind Popups sichtbar runder als
|
||||
alle Kacheln daneben.
|
||||
|
||||
**Empfehlungscharakter (keine Umsetzung hier):** Entweder die iOS-Optik als neue verbindliche
|
||||
Linie dokumentieren (und `bauauftrag.md`-/`SPECIFICATION.md`-Regeln als ÜBERHOLT markieren, wie
|
||||
es die Projektkonvention für Entscheidungen vorsieht) — oder die Auflage auf das zurückschneiden,
|
||||
was mit der CI vereinbar ist. Der jetzige Zustand ist ein unbeschlossener Bruch.
|
||||
|
||||
## B. Rot-Semantik invertiert (hoch, visuell belegt)
|
||||
|
||||
- **Setup-Popup: „Abbrechen" rot gefüllt, „Speichern" schwarz** (Screenshot
|
||||
`iphone-hell-15e-setup-popup.png`). Nach der eigenen Systematik (`.aktion` =
|
||||
Umriss-Sekundäraktion, `.primaer` = gefüllt, Rot = destruktiv) liegt die Signalfarbe auf der
|
||||
falschen Aktion.
|
||||
- Gleiche Ursache (`ios.css .aktion`-Füllung) auf: „Einrichten" (Einstellungen), „Setup —
|
||||
Sensoren zuordnen" + schwarzes „Fertig" daneben (Einrichten-Formular), „In den Kalender
|
||||
übernehmen" (Service, Reifen), beide Notrufnummern (Versicherung — drei rote Großflächen
|
||||
untereinander auf einer Seite, `iphone-hell-07-versicherung.png`).
|
||||
- Ebenfalls rot gefüllt: alle Schalter im Ein-Zustand (A7-Rückbau), aktive Navigation
|
||||
(`.tab.on` rosa hinterlegt, Desktop-Sidebar).
|
||||
- Kleinere Fälle: `--red` als Balkensegment für die neutrale Kategorie „Arbeitsweg"
|
||||
(`audi-dashboard-app.js:2423`) und im Versicherungs-Beitragsbalken (`:1628`).
|
||||
|
||||
## C. `design-system/` — Vor-Audit-Stand als DataMetric360-Basis (hoch)
|
||||
|
||||
| Befund | Fundstelle | Schwere |
|
||||
|---|---|---|
|
||||
| `--fg3: #657081` — der im Audit als kontrastschwach identifizierte Wert (3,0:1 auf `--tile`), im Panel seit 2026-08-10 auf `#8a94a3` korrigiert; Bibliothek **und** `design/_ds`-Bundle tragen ihn weiter | `design-system/src/tokens/tokens.css:33`, `design/_ds/.../_ds_bundle.css:20` | hoch |
|
||||
| **Kein einziger Fokus-Stil** in Tokens + allen 20 Komponenten (Audit fordert `:focus-visible` 2 px `#F50537`; das Panel hat ihn) | gesamtes Paket, grep-verifiziert | hoch |
|
||||
| Formularfelder **13,5 px** → iOS-Auto-Zoom (Audit-Regel ≥16 px); propagiert bis in den `design/`-Entwurf (Bundle-Regel gewinnt gegen `.dm-in`) | `Feld/Feld.css:24-33`; `DM360.dc.html:24/41/44/422-428` | hoch |
|
||||
| Versalien-Sperrschrift in 6 Komponenten über die bekannten `.ads-label`/`.ads-eyebrow` hinaus; StatGrid/TabBar mit nur **9 px** | `ActionButton.css:7`, `Pill.css:6`, `Seg.css:14`, `SwipeRow.css:16`, `StatGrid.css:15`, `TabBar.css:39` | mittel |
|
||||
| TabBar: aktiver Tab = **roter Oberkantenstrich** statt gefüllter Pille — exakt das Muster, das der Audit (B5) im Panel abgeschafft hat | `TabBar.css:22-24` | mittel |
|
||||
| Trefferflächen: IconButton 34×34, Switch 46×27, Popup-Items ≈34 px, Seg ≈35 px | jeweilige Komponenten-CSS | mittel |
|
||||
| Literalfarben (`#fff` auf Switch-Knopf und SwipeRow) | `Switch.css:35`, `SwipeRow.css:11` | niedrig |
|
||||
| Englischer Default-UI-Text `deleteLabel = "Delete"` | `SwipeRow.tsx:27` | niedrig |
|
||||
| **Positiv: Markenreinheit bestanden** — keine Fonts/Ringe/Typenschilder im Paket (Lizenzregel eingehalten); keine Gewichte ≥500, keine Schatten, Radius über Token | — | ✓ |
|
||||
|
||||
## D. `design/`-Export (mittel)
|
||||
|
||||
- **Der RS-6-Fehler reicht bis in die Fachdaten**, nicht nur Badge/Name: „V8 biturbo · 600 PS",
|
||||
441 kW, 3.996 cm³, 2.150 kg, Tank **73 l** (RS 4: 58 l), Reifen **285/30 R22**, FIN-Baureihe
|
||||
„4G" statt „8W" (`DM360.dc.html:116-628` diverse). Die korrekten RS-4-Schilder liegen bereits
|
||||
unter `design/uploads/RS-S Badges/` — nur `assets/` enthält die RS-6-Kopien.
|
||||
- **`design/datametric360-ios.css` und `homeassistant/www/audi-dashboard-ios.css` sind divergente
|
||||
Geschwister**, keine Kopien: der `.ads`-Variante fehlen Fokusring, gefüllte Tab-Pille (sie färbt
|
||||
stattdessen das Icon rot) und das komplette ≥860-px-Layout; die Panel-Variante hat dafür die
|
||||
Schatten. Zwei Wahrheiten für dieselbe Optik → Drift vorprogrammiert.
|
||||
- **Einstellungen-Einstieg widersprüchlich:** Entwurf + `DESIGN_BRIEF` sagen Zahnrad oben rechts,
|
||||
Panel/`SPECIFICATION` sagen Audi-Ringe (Zahnrad erst ≥860 px). Vor der App-Umsetzung entscheiden.
|
||||
- IconButton-Hints 36 px (`DM360.dc.html:78, 90`); variable Audi Type mit `font-weight: 100 900`
|
||||
registriert (lädt zur Verletzung der 300/400-Regel ein, genutzt werden nur 300/400);
|
||||
Literalfarben/Schatten im Präsentationsrahmen (nur Board, nicht App-UI).
|
||||
- `companion-app/` auf `main` ist nur die Datenschicht; der dort noch offene Feldnamen-Defekt
|
||||
(`tank_prozent`/`sicher_abgestellt`/`sicherheit` vs. Backend) ist auf dem Arbeitsbranch behoben
|
||||
und löst sich beim Merge — bis dahin auf `main` latent.
|
||||
|
||||
## E. Panel-Frontend: weitere Verstöße gegen die Vorgaben (Auswahl)
|
||||
|
||||
**Farben nur als Tokens:**
|
||||
- **iOS-Systemblau `#0A84FF`** als palettenfremde Farbe für den Nutzer-Pin
|
||||
(`audi-dashboard.css:702, :707`) — Blau existiert in der Palette nicht. (hoch)
|
||||
- Kartensteuerung komplett aus Literalen (`rgba(16,20,26,.84)`, `#fff`, `#101418`;
|
||||
`audi-dashboard.css:722-734`), Leaflet-Startmarker `#fff`/`#000`
|
||||
(`audi-dashboard-app.js:459`), Scrims 2× `rgba(0,0,0,.45)` ohne Token. (mittel/niedrig)
|
||||
|
||||
**Typografie:**
|
||||
- **Zweite und dritte Versalien-Stelle** neben `.eyebrow`: `.setup-gruppe-titel` (uppercase,
|
||||
.1em — im Setup-Popup sichtbar) und `.marke-logo .ph` (`audi-dashboard.css:913, :481`). (mittel)
|
||||
- Die selbst gesetzte Regel „`--fg3` nie unter 12 px" wird an ≥7 Stellen gebrochen
|
||||
(`.sync` 10,5 px, `.quad .l` 11 px, `row dd small` 11,5 px, mehrere Inline-11-px;
|
||||
Kontrast 4,6:1 ist unter der WCAG-Großtext-Schwelle für so kleine Schrift). (mittel)
|
||||
|
||||
**Layout/A11y:**
|
||||
- Schatten/Blur auch im **Haupt-CSS** (nicht nur ios.css): Pins, Kartensteuerung
|
||||
(+`backdrop-filter`), Standortmenü, Setup-Entitätenliste (`audi-dashboard.css:697-745, :950`). (mittel)
|
||||
- Trefferflächen <44 px bei neuen Elementen: Standortmenü-Schließen 32 px,
|
||||
Setup-Reset 26 px, Fahrtart-Pille ≈31 px, `.rings` im Basis-CSS nur 42×24 px (erst ios.css
|
||||
hebt an — ohne geladene Auflage ist der einzige Einstellungszugang zu klein). (mittel)
|
||||
- Bei ausgeschalteter Tab-Beschriftung sind **alle fünf Tabs namenlos** (Text per
|
||||
`display:none`, SVG `aria-hidden`, kein `aria-label`; `audi-dashboard-app.js:3072-3074`,
|
||||
`audi-dashboard.css:266`). (mittel)
|
||||
- Escape schließt Sheet und Setup, aber nicht Bildmenü/SmartDeal-Popup/Standortmenü. (niedrig)
|
||||
- Koordinaten als einzige nicht-de-DE-Zahl im UI (`toFixed(4)` mit Punkt,
|
||||
`audi-dashboard-app.js:786-789`). (niedrig)
|
||||
|
||||
**Auf `main` bereits behoben** (bekannte Befunde, die nicht mehr gelten): die Ringe sind
|
||||
inzwischen ein echter `<button aria-label="Einstellungen">`, und das Setup-Popup **hat** Fokusfalle
|
||||
+ Escape (`audi-dashboard-app.js:3054-3070`) — die Einträge in `REVIEW_main_2026-08-13.md` §7.10/6.13
|
||||
sind insoweit überholt (Rest: Trefferfläche).
|
||||
|
||||
## F. Im Browser nachgewiesene Darstellungs-/Zustandsfehler
|
||||
|
||||
**F1 — Schalter werden gestreckt und zeigen widersprüchlichen Zustand (Ursache identifiziert).**
|
||||
`audi-dashboard.css:493` `.feld label { flex: 1 1 auto }` trifft auch `label.switch` — der Schalter
|
||||
in einer `.feld`-Zeile wächst mit dem freien Platz (gemessen: **277–319 px statt 51 px** im
|
||||
Desktop-Einrichten-Formular; auch am iPhone auf der Reifen-Seite sichtbar verbreitert). Da der
|
||||
Knopf fix um `translateX(20px)` wandert, steht er auf der gestreckten Bahn optisch **links
|
||||
(= „Aus"), obwohl die Bahn rot (= „Ein") ist**. Screenshots `desktop-hell-14-einstellungen.png`,
|
||||
`iphone-hell-08-reifen.png`. (hoch — Zustandsanzeige mehrdeutig)
|
||||
|
||||
**F2 — Offene Fahrten zeigen zweimal „offen" untereinander.** In der Fahrtenliste rendert der
|
||||
Wert-Slot `"offen"` und das `<small>` darunter `t.status` = „offen"
|
||||
(`audi-dashboard-app.js:2275`). Screenshot `iphone-hell-09-trips.png`. (niedrig)
|
||||
|
||||
**F3 — Ölwechsel-Kachel der Übersicht bleibt komplett leer** bei leerem Servicebuch: `termine()`
|
||||
baut nur auf Servicebuch-Einträgen auf (`audi-dashboard-app.js:1522-1534`); ohne Eintrag sind
|
||||
`km`/`datum`/`kmDatum` null und die Kachel zeigt **nur das Label ohne jeden Wert** — kein
|
||||
Leerzustand nach der eigenen D5-Regel („Ursache und nächste Handlung nennen"), und die vom
|
||||
Fahrzeug gemeldeten Fälligkeiten (`oelwechsel_faellig_ts/km` waren im Test gesetzt) werden auf
|
||||
der Übersicht nicht herangezogen. Auch die Service-Seite zeigt daneben nur „kein Eintrag im
|
||||
Servicebuch". Screenshots `iphone-hell-01-home.png`, `iphone-hell-06-service.png`. (mittel)
|
||||
|
||||
**F4 — Alle drei Leaflet-Karten rendern im Harness nur fragmentarisch** (Übersichts-Kachel,
|
||||
Standort-Vollbild, Einzelfahrt/Beleg): Kacheln erscheinen nur in Teilbereichen, der Rest bleibt
|
||||
canvas-farbig — unverändert auch nach 4 s Wartezeit, also kein Ladeproblem, sondern
|
||||
Initialisierungs-/`invalidateSize`-Timing (Karte wird aufgebaut, bevor der Container seine
|
||||
endgültige Größe hat). **Am echten HA/Gerät verifizieren** — falls dort reproduzierbar, ist es
|
||||
der sichtbarste Fehler der neuen Standort-Features. Screenshots `iphone-hell-02b-standort-lang.png`,
|
||||
`desktop-hell-01-home.png`, `iphone-hell-10-trip-detail.png`. (verifizieren)
|
||||
|
||||
**Weitere Beobachtungen (verifizieren, evtl. Harness-Artefakte):**
|
||||
- Native Datumsfelder zeigten `mm/dd/yyyy` (Werkstatttermin, Reifenwechsel) — `<input type=date>`
|
||||
formatiert nach System-Locale; am deutschen iPhone vermutlich korrekt, auf Desktop-Browsern mit
|
||||
englischer UI nicht. Kein Codefehler, aber ein bekannter Plattform-Seiteneffekt.
|
||||
- FIN-Wert im Einrichten-Formular rechts abgeschnitten ohne Ellipse
|
||||
(`iphone-hell-15-setup.png`).
|
||||
- Im Desktop-Layout wirkte der Fahrzeugbild-Platzhalter der Übersicht gestaucht
|
||||
(Beschriftung ragt an die Kachelkante, `desktop-hell-01-home.png`).
|
||||
|
||||
## G. Was konsequent eingehalten wird (positiv)
|
||||
|
||||
- **Schriftgewichte:** kein einziges `font-weight` ≥500 in ~5.300 Zeilen Panel-Code; alle
|
||||
`<strong>`-Kontexte bewusst auf 400 neutralisiert; `tabular-nums` global. Auch design-system
|
||||
und Entwurf nutzen nur 300/400.
|
||||
- **de-DE-Disziplin:** zentrales `de()/eur()/dedat()`-Trio, jeder Locale-Aufruf explizit
|
||||
`"de-DE"`, ISO nur wo die Plattform es verlangt; im Entwurf durchgehend deutsche Texte und
|
||||
Formate. Einzige Grauzone: Koordinaten (E, niedrig).
|
||||
- **Marke:** Typenschild exakt 18 px mit CSS-Theme-Wechsel (positive/negative-SVG); Marke steht
|
||||
in jedem Layout genau einmal (Ringe mobil, `.navmarke` in der Desktop-Sidebar + Zahnrad);
|
||||
`design-system/` nachweislich asset-frei — die harte Lizenzregel wird eingehalten.
|
||||
- **A11y der neuen Features:** Kartensteuerung mit präzisen `aria-label` + `aria-pressed`,
|
||||
Standortmenü-Griff mit `aria-expanded/-controls` und 88×44-px-Trefferfläche, Setup-Dialog mit
|
||||
`role="dialog"`, Fokusfalle und Escape; 16-px-Formularregel im Panel konsequent (inkl. neuem
|
||||
Combo-Input).
|
||||
- Ehrliche Leerzustände („kein Eintrag im Servicebuch", „Fahrt ohne Ortsangabe", Startort
|
||||
„unbekannt") statt erfundener Werte — mit Ausnahme F3.
|
||||
|
||||
## H. Widersprüche in den Vorgaben selbst (fürs Aufräumen der Dokumente)
|
||||
|
||||
1. `--fg3` hat **drei Werte im Repo**: `#657081` (bauauftrag, design-system, \_ds-Bundle) vs.
|
||||
`#8a94a3` (SPECIFICATION, Panel) — der Audit hat nur SPEC nachgezogen.
|
||||
2. Kontrastangabe inkonsistent: CSS-Kommentar sagt „4,6:1", der Audit rechnet 5,6:1/4,9:1.
|
||||
3. `SPECIFICATION.md` ist bei Platzhaltern (nennt noch „exact expected filename", vom Audit
|
||||
abgeschafft) und bei `confirm()`/Action-Sheet (B4) veraltet; §7.8 behauptet, `--sp-*`/`--r-func`
|
||||
existierten nicht — sie existieren, werden aber (das stimmt) teils nicht genutzt.
|
||||
4. `DESIGN_BRIEF` (Zahnrad, Live-Telemetrie) widerspricht `bauauftrag.md`/`SPECIFICATION.md`
|
||||
(Ringe, „keine Live-Telemetrie"); Auflösung über FMM003 ist nirgends explizit festgehalten.
|
||||
5. Bildmaße: `bauauftrag.md` nennt 206/165/78 px, der Code nutzt aspect-ratio + 96 px — Absicht
|
||||
erfüllt, Zahlen nie als überschrieben dokumentiert.
|
||||
|
||||
## Zählung (konsolidiert, ohne Doppelzählung der Sammelthemen)
|
||||
|
||||
| Bereich | hoch | mittel | niedrig | verifizieren |
|
||||
|---|---|---|---|---|
|
||||
| A/B iOS-Auflage + Rot-Semantik | 2 Themenkomplexe (≥10 Einzelstellen) | — | — | — |
|
||||
| C design-system | 3 | 3 | 2 | — |
|
||||
| D design/-Export | — | 4 | 3 | — |
|
||||
| E Panel-Einzelbefunde | 1 | 8 | 6 | — |
|
||||
| F Browser-Nachweise | 1 | 1 | 1 | 4 |
|
||||
|
||||
## Annex: Screenshots
|
||||
|
||||
Liegen (sitzungsgebunden, nicht im Repo) unter
|
||||
`/private/tmp/claude-501/-Users-paul-Development-audi-app/a6c2c477-dbf4-43ec-9a25-88d762b653bf/scratchpad/harness/shots/`.
|
||||
Benennung: `iphone-hell-*` (390×844 hell), `iphone-dunkel-*`, `desktop-hell-*` (1440×900).
|
||||
Harness: `…/scratchpad/harness/` (`index.html` = Mock-`hass`, `shots*.mjs` = Playwright-Läufe,
|
||||
`profil.json` = Beispielprofil) — reproduzierbar gegen jeden Stand von `homeassistant/www/`.
|
||||
|
||||
Aussagekräftigste Belege: `15e-setup-popup` (Abbrechen rot/Speichern schwarz),
|
||||
`14-einstellungen` + `desktop-14` (rote Flächen, gestreckte Schalter), `07-versicherung`
|
||||
(3 rote Großflächen), `08-reifen` (Schalter-Zustand mehrdeutig), `09-trips` (doppeltes „offen"),
|
||||
`01-home` (leere Ölwechsel-Kachel, Kartenfragment), `02b-standort-lang` (Kartenfragment nach 4 s).
|
||||
@@ -0,0 +1,149 @@
|
||||
# Was jetzt zu tun ist (DM360)
|
||||
|
||||
**Die eine Datei, die den aktuellen Stand nennt.** Wer wissen will, was ansteht,
|
||||
liest hier - nicht in 12.000 Zeilen `AGENTS.md`.
|
||||
|
||||
Die drei Dateien teilen sich die Arbeit so:
|
||||
|
||||
| Datei | beantwortet |
|
||||
|---|---|
|
||||
| **`OFFEN.md`** (hier) | **Was ist zu tun?** |
|
||||
| `APPLE_DEV_BACKLOG.md` | Was davon braucht Xcode oder das Apple-Portal? |
|
||||
| `AGENTS.md` | **Warum** ist etwas so, wie es ist - die Geschichte, nicht der Zustand |
|
||||
|
||||
**Arbeitsregel (verbindlich):** diese Datei wird in **derselben Sitzung**
|
||||
nachgeführt, in der sich etwas ändert - erledigt, dazugekommen, entschieden.
|
||||
Eine Liste, die man erst am Monatsende pflegt, ist keine.
|
||||
Aufruf: **`/stand`** prüft den mechanischen Teil selbst nach und liest diese
|
||||
Datei dazu.
|
||||
|
||||
---
|
||||
|
||||
## 1. Wartet auf Sie
|
||||
|
||||
### 1.1 Ein Xcode-Lauf - **zwei Funktionen sind auf dem Telefon tot**
|
||||
|
||||
Zwischenablage und Kalendereintrag sind in der installierten App wirkungslos.
|
||||
Beide Korrekturen liegen im Repository und warten nur auf einen Lauf.
|
||||
|
||||
*Was zu tun ist:* am Mac nur
|
||||
`bash companion-app/scripts/ios-signieren.sh`, danach die fertige `.ipa` pushen
|
||||
(das Telefon lädt sie aus dem Repository). Ein Befehl - das Skript prüft sich
|
||||
selbst und bricht ab, wenn etwas fehlt; von Hand bleiben das
|
||||
Schlüsselbund-„Immer erlauben" beim ersten Signieren und die Probe auf dem
|
||||
Gerät.
|
||||
|
||||
**Die Datei, die man dafür in die Hand nimmt, ist
|
||||
[`APPLE_DEV_BACKLOG.md`](APPLE_DEV_BACKLOG.md)** - sie trägt den Ablauf, den
|
||||
Stand und die Prüfliste; Abschnitt „Wie ein Lauf abläuft", dann A1 und A2.
|
||||
|
||||
### 1.2 Gitea-Token für den Laborzweig DM180
|
||||
|
||||
Nicht dieses Repository - der Laborzweig (`tobias/Data-Metric-180`, privat).
|
||||
Seine Selbstaktualisierung zeigt seit dem 06.09.2026 auf das eigene Repository,
|
||||
ihr fehlt nur noch der Zugang.
|
||||
|
||||
*Was zu tun ist:* in der Laborinstanz unter Einstellungen -> Geräte & Dienste ->
|
||||
Audi Dashboard -> Konfigurieren einen Gitea-Token mit Lesezugriff eintragen.
|
||||
|
||||
### 1.3 `steuer.faellig` ist leer
|
||||
|
||||
Betriebsdaten, kein Code - die Kfz-Steuer hat kein Fälligkeitsdatum.
|
||||
|
||||
*Was zu tun ist:* in der App unter Mein Audi -> Versicherung/Steuer eintragen.
|
||||
|
||||
---
|
||||
|
||||
## 2. Wartet auf eine Entscheidung von Ihnen
|
||||
|
||||
### 2.1 Die Übersicht-Box „Versicherung/Steuer" neu gestalten
|
||||
|
||||
Seit dem 19.08.2026 vorgemerkt und **bewusst nicht begonnen**: Sie hatten
|
||||
gesagt, Sie wüssten noch nicht, was Sie dort sehen wollen. Ein Entwurf ohne
|
||||
diese Antwort wäre geraten.
|
||||
|
||||
*Was zu tun ist:* ein kurzes Gespräch darüber, was auf einen Blick sichtbar sein
|
||||
soll und was auf die Detailseiten gehört. Danach wie üblich erst ein Muster,
|
||||
dann Code.
|
||||
|
||||
---
|
||||
|
||||
## 3. Wartet auf eine Sitzung
|
||||
|
||||
Buildbar, ohne Rückfrage, in dieser Reihenfolge sinnvoll:
|
||||
|
||||
### 3.1 Versicherung: PDF der Vertragsdetails ablegen
|
||||
|
||||
Die Police als PDF an den Vertrag hängen und später wieder ansehen können.
|
||||
|
||||
*Was zu tun ist:* zuerst den Ablageort entscheiden - spiegelt es die vorhandene
|
||||
Fotoablage unter `/config/audi_dashboard/`, oder wird es ein eigenes
|
||||
Anhang-Konzept? Danach Dienst, Ablage und beide Oberflächen (Paritätsregel).
|
||||
|
||||
### 3.2 Versicherung: freies Notizfeld
|
||||
|
||||
Eine eigene Zusammenfassung zur Versicherung, aus keinem strukturierten Feld
|
||||
abgeleitet.
|
||||
|
||||
*Was zu tun ist:* Profilfeld, Textfeld in beiden Oberflächen. Das Panel hat mit
|
||||
`.feld--breit` bereits das Muster für ein Feld über die volle Breite.
|
||||
|
||||
### 3.3 Restliche Dokumentendrift
|
||||
|
||||
`SPECIFICATION.md`, `homeassistant/README.md` und die Profilvorlage nennen noch
|
||||
den WLAN-Sensor, der am 12.08.2026 entfallen ist; dazu eine falsche Aussage über
|
||||
Beispielzahlen in der Statistik und ein überholter TODO-Kommentar.
|
||||
|
||||
*Was zu tun ist:* reine Textkorrekturen, kein Code.
|
||||
|
||||
---
|
||||
|
||||
## 3b. Optisch noch nicht gesehen
|
||||
|
||||
**Erledigt am 07.09.2026.** Die Markierung überschrittener Servicetermine
|
||||
(Abschnitt DJ) ist jetzt auch im **Panel** live gesehen und gemessen -
|
||||
Übersicht und „Mein Audi", Tagmodus, Fassung 2026.9.6.9.
|
||||
|
||||
Das Erstzulassungsdatum im Testcontainer war dafür vorübergehend auf
|
||||
01.03.2020 zurückdatiert und ist am 07.09.2026 auf **01.03.2025**
|
||||
zurückgestellt; die Sicherung `fahrzeugprofil.json.ansicht` ist entfernt.
|
||||
|
||||
*Offen bleibt allein:* der **Nachtmodus** ist nur gerechnet, nicht gesehen.
|
||||
|
||||
Die Beschreibung zum Nachziehen in einem Spin-off steht in
|
||||
`PORTIERUNG_UEBERSCHRITTEN.md`; als teilbare Seite unter
|
||||
<https://claude.ai/code/artifact/8f2eb15e-1107-45bc-bd04-78b19fa06a4b>.
|
||||
|
||||
## 4. Gemeldet, aber nicht reproduzierbar
|
||||
|
||||
Beide mehrfach gesucht und **nicht gefunden**. Sie bleiben stehen, bis sie
|
||||
wieder auftreten - raten hilft hier nicht.
|
||||
|
||||
* **Das Kartenebenen-Symbol im Standort-Blatt** wirkt fehlerhaft. Dreimal
|
||||
geprüft (Markup, Funktion, gerechnete Farbwerte), jedes Mal deckungsgleich mit
|
||||
dem Panel. *Was hilft:* ein Bildschirmfoto vom Gerät.
|
||||
* **Die Batteriespannungs-Zeile lässt sich am Rechner nicht antippen.** Mit
|
||||
Trefferpunkt-Prüfung, synthetischen und echten Klicks getestet - navigiert
|
||||
jedes Mal korrekt. *Was hilft:* die Fensterbreite und ein Blick in die
|
||||
Browser-Konsole im Moment des Fehlversuchs.
|
||||
|
||||
---
|
||||
|
||||
## 5. Bekannte Grenzen - bewusst so, nicht offen
|
||||
|
||||
Damit sie nicht als Fehler wiederkommen:
|
||||
|
||||
* **Der Fahrtbeginn liegt bis zu neun Minuten zu spät.** Der Dongle wacht erst
|
||||
über den Beschleunigungssensor auf; die verschlafenen Kilometer holt die App
|
||||
seit `2026.9.6.2` aus dem Kilometerstand zurück, den *Zeitpunkt*
|
||||
zurückzurechnen haben Sie abgelehnt, solange es eine gerechnete statt
|
||||
gemeldeten Zahl wäre.
|
||||
* **Werkbank und Fahrzeug lassen sich nicht automatisch trennen.** Ein
|
||||
Werkbank-Schalter wurde am 06.09.2026 angeboten und abgelehnt - Sie
|
||||
entscheiden selbst, welche Messwerte bleiben, über das Wischen in der
|
||||
Messwertliste. **Nicht erneut vorschlagen.**
|
||||
* **Das Panel wird nicht gelöscht, sondern archiviert** - aber erst, wenn die App
|
||||
es wirklich abgelöst hat. Heute nicht.
|
||||
* **HACS ist ein Sackgassenweg**, dauerhaft: es lehnt private Repositories
|
||||
kategorisch ab. `install.ps1` ist der einzige Installationsweg, die
|
||||
Selbstaktualisierung der einzige Aktualisierungsweg.
|
||||
@@ -0,0 +1,246 @@
|
||||
# Überschrittene Servicetermine — Beschreibung zum Nachziehen
|
||||
|
||||
Stand 07.09.2026, Fassung `2026.9.6.9`, Commit `33c2ffa` (und Vorläufer).
|
||||
Gedacht für ein Spin-off dieser App: alles, was gebraucht wird, um dieselbe
|
||||
Markierung dort noch einmal zu bauen — einschließlich der Gründe, damit die
|
||||
Entscheidungen nicht neu erraten werden müssen.
|
||||
|
||||
**Teilbare Fassung:** dieselbe Beschreibung als Seite, mit echt gerenderten
|
||||
Farbmustern für Tag und Nacht -
|
||||
<https://claude.ai/code/artifact/8f2eb15e-1107-45bc-bd04-78b19fa06a4b>
|
||||
(standardmäßig privat; teilbar über das Teilen-Menü auf der Seite). Sie ist
|
||||
markenfrei gehalten: keine lizenzierten Schriften, Embleme oder Modellzeichen.
|
||||
Änderungen an dieser Datei dort nachziehen, sonst laufen die beiden Fassungen
|
||||
auseinander.
|
||||
|
||||
Betroffen sind **zwei Codebasen**, die nach der Paritätsregel immer denselben
|
||||
funktionalen Stand haben: das Home-Assistant-Panel
|
||||
(`custom_components/audi_dashboard/frontend/`) und die Companion-App
|
||||
(`companion-app/src/`). Wer nur eine davon nachzieht, hat einen latenten Fehler.
|
||||
|
||||
---
|
||||
|
||||
## 1. Was die Markierung leistet
|
||||
|
||||
Ein Servicetermin, dessen Fälligkeitstag erreicht oder überschritten ist, wird
|
||||
an drei Stellen hervorgehoben:
|
||||
|
||||
| Ort | Darstellung |
|
||||
|---|---|
|
||||
| Service-Blatt | die betroffene Termingruppe blass gelb hinterlegt, Marke „überschritten" im Kopf |
|
||||
| „Mein Audi", Service-Kachel | **nur die betroffene Zeile** blass gelb, Marke links vom Wert |
|
||||
| Übersicht, Kachel „Nächster Service" | Kennzahl in kräftigem Orange, Marke rechts daneben |
|
||||
|
||||
Zwei Randbedingungen, die der Eigentümer ausdrücklich gesetzt hat:
|
||||
|
||||
* Auf der Übersicht darf die Marke die Kachel **nicht höher machen** — sie steht
|
||||
deshalb in derselben Zeile wie die Kennzahl, nicht darunter.
|
||||
* In „Mein Audi" wird **nicht die ganze Kachel** hinterlegt, sondern nur das
|
||||
überschrittene Feld. Eine Zwischenfassung hatte die Kachel als Ganzes getönt;
|
||||
das wurde zurückgewiesen.
|
||||
|
||||
---
|
||||
|
||||
## 2. Die Fachregel: entschieden wird am Datum
|
||||
|
||||
`terminUeberschritten(...kandidaten)` — im Panel in `audi-dashboard-app.js`, in
|
||||
der App exportiert aus `src/daten/service.ts`.
|
||||
|
||||
**Regel:** Nimm das **erste lesbare Datum** der übergebenen Kandidaten und melde
|
||||
`true`, sobald es nicht mehr in der Zukunft liegt. Der Fälligkeitstag selbst
|
||||
zählt mit.
|
||||
|
||||
Drei Punkte, die beim Nachbau leicht falsch gemacht werden:
|
||||
|
||||
1. **Nicht an den Restkilometern entscheiden.** Die Kilometerseite trägt je nach
|
||||
Quelle ein anderes Vorzeichen: die Fahrzeugsensoren melden negativ, solange
|
||||
noch Strecke bleibt (gemessen: `oil_change_distance` = −28600 bei Fälligkeit
|
||||
2028), und die Prognosefunktion klemmt ihre Restkilometer mit
|
||||
`Math.max(0, …)`. Welches Vorzeichen ein überschrittener Fahrzeugwert trägt,
|
||||
ist an den vorliegenden Daten **nicht beobachtbar**. Der km-Fall fällt ohnehin
|
||||
unter die Datumsregel, weil die Prognose das Datum auf heute setzt, sobald die
|
||||
Restkilometer auf 0 laufen.
|
||||
2. **Das erste lesbare Datum nehmen, nicht das früheste.** Es ist genau das
|
||||
Datum, das an der Stelle auch angezeigt wird — so kann die Markierung nicht
|
||||
von dem abweichen, was daneben steht.
|
||||
3. **Volle ISO-Zeitstempel mitlesen.** Die Fahrzeugmeldung liefert
|
||||
`"2027-10-21T00:00:00+00:00"`, und sie ist bei Ölwechsel und Inspektion die
|
||||
Hauptquelle. Der verankerte Textparser lehnt Zeitstempel ab; ohne einen
|
||||
eigenen Zweig dafür gälte die Hauptquelle stillschweigend **nie** als
|
||||
überschritten. Im Panel macht das `zeitstempelAlsDatum()` mit einem auf
|
||||
`JJJJ-MM-TT` plus `T` verankerten Ausdruck; in der App kann `alsZeitpunkt()`
|
||||
das bereits. Der Fehler wurde vom Test gefangen, nicht im Betrieb — er wäre
|
||||
still geblieben.
|
||||
|
||||
Bewusst eng gefasst statt einem allgemeinen `new Date()`: sonst deutete der
|
||||
Rückfall auch Zeichenketten, die der Textparser gerade abgelehnt hat.
|
||||
|
||||
---
|
||||
|
||||
## 3. Die Farben: jedes Element an seiner eigenen WCAG-Schwelle
|
||||
|
||||
Das ist der Teil, der zwei Anläufe gebraucht hat, und der Grund dafür ist
|
||||
übertragbar: **kräftiges Orange ist bei 4,5:1 nicht zu haben.** Apples
|
||||
systemOrange `#FF9500` kommt auf Weiß auf 2,20:1; jedes Orange, das 4,5:1
|
||||
schafft, ist so dunkel, dass es als Braun gelesen wird. Zwei Anläufe mit einer
|
||||
einzigen Textfarbe (`#A15800`, dann Apples `#C55300`) wurden beide als „braun
|
||||
statt gelb" zurückgewiesen — zu Recht.
|
||||
|
||||
Die Lösung ist nicht ein besserer Kompromisston, sondern **die Trennung der
|
||||
Rollen**, jede an ihrer eigenen Schwelle:
|
||||
|
||||
| Rolle | Größe | Schwelle | Tag | Nacht |
|
||||
|---|---|---|---|---|
|
||||
| `--warn` (Fläche, 12 % hinterlegt) | — | — | `#FF9F0A` | `#FFD60A` |
|
||||
| `--warn-fig` (Kennzahl) | 32 px = Großtext | 3:1 | `#DD6000` | `#FF9F0A` |
|
||||
| `--warn-marke-grund` (Pille) | — | — | `#C55300` | `#FFA056` |
|
||||
| `--warn-marke-schrift` | 10,5 px = Kleintext | 4,5:1 | `#FFFFFF` | `#171B21` |
|
||||
| `--warn-text` (Schrift auf hellem Grund) | klein | 4,5:1 | `#C55300` | `#FFA056` |
|
||||
|
||||
Gemessen (WCAG 2.1, Hülle = 12 % `--warn` auf dem jeweiligen Kachelgrund):
|
||||
|
||||
* `#DD6000` → 3,65:1 auf Weiß, 3,34:1 auf der Hülle (Schwelle 3:1) ✔
|
||||
* Weiß auf `#C55300` → 4,55:1 (Schwelle 4,5:1) ✔
|
||||
* `#FF9F0A` auf `#171B21` → 8,41:1 ✔
|
||||
* `#171B21` auf `#FFA056` → 8,57:1 ✔
|
||||
|
||||
**Der entscheidende Kniff bei der Marke:** keine dünne Schrift, sondern eine
|
||||
**gefüllte Pille**. Derselbe Ton liest sich als Fläche eindeutig als Orange,
|
||||
während er als 1 px starker Buchstabenstrich bräunlich wirkt. Weiße Schrift auf
|
||||
`#C55300` erfüllt die 4,5:1 exakt so, wie es umgekehrt nicht ginge.
|
||||
|
||||
Tokens liegen in `frontend/audi-dashboard-ios.css` (Panel) und
|
||||
`design-system/src/tokens/tokens.css` (App) — beide Sätze müssen gleich sein.
|
||||
|
||||
**Zur Frage HIG oder Audi CI:** die Fläche ist Apple (iOS-Palette, 2026-08-13
|
||||
übernommen); das Audi-CI-Gelb `#ffaa00` wurde damals abgelöst und liefert als
|
||||
Schrift nur 1,91:1. Die Schriftwerte sind **gerechnet, nicht übernommen** —
|
||||
Apples eigener Kontrastwert `#A16A00` liefert auf der 12 %-Hülle nur 4,19:1.
|
||||
Nebenbefund: Apples HIG-Seite führt inzwischen die neue („unified") Palette;
|
||||
einen einzelnen Wert daraus zu übernehmen wäre inkonsistent gewesen.
|
||||
|
||||
---
|
||||
|
||||
## 4. CSS
|
||||
|
||||
Gleiche Regeln in beiden Codebasen, nur andere Klassennamen
|
||||
(Panel `audi-dashboard.css` / App `src/stile/screens.css`):
|
||||
|
||||
| Panel | App |
|
||||
|---|---|
|
||||
| `.termingruppe.ueberschritten` | `.dm-termingruppe--ueberschritten` |
|
||||
| `.row.ueberschritten` | `.dm-wertzeile--ueberschritten` |
|
||||
| `.ueberschritten-marke` | `.dm-ueberschritten-marke` |
|
||||
| `.serviceblock.ueberschritten .fig` | `.dm-serviceblock--ueberschritten .ads-fig` |
|
||||
| `.fig-zeile` | `.dm-servicefig` |
|
||||
|
||||
Die Tönung:
|
||||
|
||||
```css
|
||||
background: color-mix(in srgb, var(--warn) 12%, transparent);
|
||||
border-radius: var(--r-tile);
|
||||
padding-left: 12px; padding-right: 12px;
|
||||
margin-left: -12px; margin-right: -12px;
|
||||
```
|
||||
|
||||
Die negativen Außenabstände sind der Punkt: sie lassen die Tönung bis an den
|
||||
Kachelrand laufen, obwohl die Zeile selbst im Innenabstand der Kachel sitzt.
|
||||
Prüfbar am gemessenen Breitenunterschied — getönte Zeile 752 px gegenüber 728 px
|
||||
der übrigen, also genau 2 × 12 px.
|
||||
|
||||
Die Pille:
|
||||
|
||||
```css
|
||||
font-size: 10.5px; font-weight: 600;
|
||||
color: var(--warn-marke-schrift); background: var(--warn-marke-grund);
|
||||
border-radius: var(--r-pill); padding: 3px 8px;
|
||||
white-space: nowrap; flex: 0 0 auto;
|
||||
display: inline-block; vertical-align: middle;
|
||||
```
|
||||
|
||||
Platzierung: `.fig-zeile .ueberschritten-marke { margin-left: 10px }` (Übersicht,
|
||||
rechts der Kennzahl in derselben Zeile) und
|
||||
`.row .ueberschritten-marke { margin-right: 8px }` („Mein Audi", links des Werts).
|
||||
|
||||
Dazu `.termingruppe.ueberschritten, .termingruppe.ueberschritten + .termingruppe
|
||||
{ border-top: 0 }` — sonst schneidet die Trennlinie durch die getönte Fläche.
|
||||
|
||||
---
|
||||
|
||||
## 5. Markup
|
||||
|
||||
**Übersicht** — die Marke muss in dieselbe Zeile wie die Kennzahl, sonst wächst
|
||||
die Kachel. Die Zeile wird deshalb **immer** gebaut, auch ohne Marke:
|
||||
|
||||
```js
|
||||
const figMarkup = `<div class="fig-zeile">${artIcon ? ciSVG(artIcon, 31) : ""}`
|
||||
+ `<div class="fig" style="font-size:${zahlGroesse}px">${hauptwert}</div>`
|
||||
+ `${ueber ? UEBERSCHRITTEN_MARKE : ""}</div>`;
|
||||
```
|
||||
|
||||
Die Kennzahl steht auf **32 px** (vorher 40 px, auf Rückmeldung „wirkt sehr groß"
|
||||
reduziert). 32 px liegt über der 24-px-Grenze für Großtext — das ist die
|
||||
Voraussetzung dafür, dass `--warn-fig` mit 3:1 auskommen darf.
|
||||
|
||||
**„Mein Audi"** — Marke **vor** dem Wert, Klasse an der Zeile, **nicht** an der
|
||||
Kachel:
|
||||
|
||||
```js
|
||||
return `<div class="row${ueber ? " ueberschritten" : ""}" ${letzte}>
|
||||
<dt>…</dt>
|
||||
<dd>${ueber ? UEBERSCHRITTEN_MARKE : ""}${haupt}…</dd></div>`;
|
||||
```
|
||||
|
||||
In der App reicht `Wertzeile` dafür eine Eigenschaft `ueberschritten?: boolean`
|
||||
durch, die die Klasse am Zeilen-`div` setzt.
|
||||
|
||||
---
|
||||
|
||||
## 6. Tests
|
||||
|
||||
* `service.test.ts` — Fälle für `terminUeberschritten()` (Grenztag, Zukunft,
|
||||
Vergangenheit, leere und unlesbare Kandidaten, Zeitstempel).
|
||||
* `paritaet_ueberschritten.test.ts` — 14 Fälle, die die Panel-Funktion **aus dem
|
||||
Quelltext herausschneiden** und gegen die App-Funktion laufen lassen. Das ist
|
||||
der Mechanismus, der die Paritätsregel überhaupt durchsetzt; ohne ihn driften
|
||||
die beiden Fassungen auseinander, ohne dass es auffällt.
|
||||
* `screens.test.tsx` — Render-Tests gegen ein überschrittenes Profil.
|
||||
* `buendelversion.test.ts` — hält `manifest.json`-Version, `bundle.json` und die
|
||||
tatsächliche `bundle.zip` zusammen. Nach jeder UI-Änderung Version heben und
|
||||
Bündel neu bauen, sonst schlägt dieser Test fehl.
|
||||
|
||||
---
|
||||
|
||||
## 7. Fallen, die Zeit gekostet haben
|
||||
|
||||
1. **Stylesheets im Panel greifen asynchron.** Direkt nach dem Neuladen liefert
|
||||
eine Messung die UA-Vorgabe — die Pille erbt dann `13,3333px` schwarz aus dem
|
||||
umgebenden `<button>`. Das sieht exakt so aus, als würde die Regel nicht
|
||||
greifen, und hat hier eine lange Fehlersuche ausgelöst (bis hin zum Einspielen
|
||||
einer Testregel zur Laufzeit, die aus demselben Grund ebenfalls „nicht
|
||||
griff"). **Vor dem Messen warten und auf den Zielwert pollen**, nicht einmal
|
||||
messen und daraus schließen.
|
||||
2. **Ein Testfall, der den Fall gar nicht auslöst.** Der erste Render-Test war
|
||||
mit einem Wartungsplan-Datum bestückt, das noch in der Zukunft lag — grün,
|
||||
aber wertlos. Ebenso: synchrone Zusicherungen gegen asynchron geladene Daten
|
||||
brauchen `waitFor`.
|
||||
3. **Der Fall muss erst erzeugt werden.** Zum Ansehen wurde die Erstzulassung im
|
||||
Testcontainer vorübergehend auf `01.03.2020` zurückdatiert (Sicherung daneben,
|
||||
hinterher zurückgestellt). Ohne das ist nichts überschritten und es gibt
|
||||
nichts zu sehen.
|
||||
4. **Der Nachtmodus ist hier nur gerechnet, nicht gesehen.** Wer ihn im Spin-off
|
||||
nutzt, sollte ihn einmal ansehen.
|
||||
|
||||
---
|
||||
|
||||
## 8. Reihenfolge zum Nachziehen
|
||||
|
||||
1. `terminUeberschritten()` in beiden Codebasen, mit dem Zeitstempel-Zweig.
|
||||
2. Tokens `--warn-fig`, `--warn-marke-grund`, `--warn-marke-schrift` in beide
|
||||
Token-Dateien.
|
||||
3. CSS-Blöcke in beiden Stildateien.
|
||||
4. Markup: Übersicht (Zeile immer bauen, 32 px), „Mein Audi" (Klasse an der
|
||||
Zeile), Service-Blatt (Termingruppe).
|
||||
5. Tests, **einschließlich** des Paritätstests.
|
||||
6. Version heben, Design-System bauen, OTA-Bündel bauen.
|
||||
7. Live ansehen — mit zurückdatiertem Datum, und mit Wartezeit vor der Messung.
|
||||
@@ -0,0 +1,108 @@
|
||||
# Audi Dashboard — „Mein Audi" für Home Assistant
|
||||
|
||||
Fahrtenbuch, Tankstatistik, Reifen- und Servicebuch für ein einzelnes
|
||||
Fahrzeug — als eigener Sidebar-Eintrag in Home Assistant und als iOS-App, die
|
||||
dieselben Daten zeigt.
|
||||
|
||||
Die App wertet aus, was Home Assistant ohnehin über das Fahrzeug weiß
|
||||
(Zündung, Kilometerstand, Tankfüllstand, Bordspannung, GPS — etwa über einen
|
||||
Teltonika FMM003 oder eine EU-Data-Act-Integration des Herstellers), und macht
|
||||
daraus einen dauerhaften Bestand: erkannte Fahrten, erkannte Tankvorgänge,
|
||||
Belegdaten aus hochgeladenen Tank-PDFs, ein Spannungsverlauf über Jahre.
|
||||
|
||||
> Privates Projekt für ein bestimmtes Fahrzeug. Es ist nicht als allgemein
|
||||
> nutzbare Integration gedacht und steht in keinem Verhältnis zur AUDI AG.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
HACS scheidet aus, und zwar endgültig: laut eigener Dokumentation
|
||||
(hacs.xyz/docs/faq/private_repositories) kann HACS **grundsätzlich nicht**
|
||||
mit privaten GitHub-Repositories arbeiten — „HACS can only get publicly
|
||||
available information", ohne Ausnahme für Tokens oder verbundene Konten. Und
|
||||
öffentlich machen ist keine Option: das Repository enthält die
|
||||
Audi-Hausschrift und die Typenschilder, die nur für diese eine private
|
||||
Installation lizenziert sind.
|
||||
|
||||
Der Installer unten ist deshalb nicht die zweite Wahl, sondern der einzige
|
||||
Weg für die **Erstinstallation** — die Integration muss laufen, bevor sie
|
||||
sich selbst aktualisieren kann.
|
||||
|
||||
Für **Updates danach** gibt es seit 2026-08-24 einen zweiten, bevorzugten
|
||||
Weg: die Integration lädt ihre neueste Fassung direkt von Gitea und ersetzt
|
||||
sich selbst (Einstellungen → Geräte & Dienste → Audi Dashboard →
|
||||
Konfigurieren, dort ein Gitea-Zugriffstoken eintragen; danach in der App
|
||||
unter Einstellungen → Integration-Update). Grund: Windows Smart App Control
|
||||
blockiert die Ausführung von `install.ps1` zuverlässig und lässt sich —
|
||||
anders als der SmartScreen davor — nicht per „trotzdem ausführen" umgehen.
|
||||
Der Installer unten bleibt der Weg für die Erstinstallation und für den
|
||||
seltenen Fall, dass das Selbst-Update selbst nicht mehr erreichbar ist.
|
||||
|
||||
Von einem Windows-Rechner aus, der das `config`-Verzeichnis der HA-Instanz
|
||||
erreicht (Samba-Add-on oder gemounteter Pfad):
|
||||
|
||||
```
|
||||
homeassistant\installationspaket\Installieren.cmd
|
||||
```
|
||||
|
||||
Das Skript kopiert `custom_components\audi_dashboard\` in die Instanz und
|
||||
fasst sonst nichts an — keine `configuration.yaml`, keine Fahrzeugdaten. Mit
|
||||
`-Pruefen` zeigt es vorher, was es tun würde. Details und die
|
||||
Sicherheitszusagen stehen im Kopf von
|
||||
[`install.ps1`](homeassistant/installationspaket/install.ps1).
|
||||
|
||||
Danach ebenfalls: neu starten, Integration hinzufügen.
|
||||
|
||||
## Nach der Installation
|
||||
|
||||
**Sensoren zuordnen** — **Einstellungen → Fahrzeug einrichten → Setup** in der
|
||||
App. Im Auslieferstand ist bewusst *kein* Sensor vorbelegt: welche Entity-IDs
|
||||
richtig sind, hängt an der Instanz, und eine gesetzte, aber falsche ID ist
|
||||
schlechter als eine leere. Zwingend ist allein der Zündungs-/ACC-Sensor — er
|
||||
trägt die Fahrterkennung. Änderungen wirken sofort, ohne Neustart.
|
||||
|
||||
**Datenaufbewahrung verlängern** — Home Assistant löscht Sensor-Verläufe nach
|
||||
10 Tagen. Das begrenzt, wie weit „Daten importieren aus Home Assistant"
|
||||
zurückreichen kann, und was einmal gelöscht ist, kommt nicht wieder. Der Block
|
||||
aus [`recorder_snippet.yaml`](homeassistant/recorder_snippet.yaml) hebt das
|
||||
auf ein Jahr an; dort steht auch der gemessene Platzbedarf. Bewusst nicht
|
||||
automatisch eingefügt: das ist eine Entscheidung über den Plattenplatz der
|
||||
Instanz.
|
||||
|
||||
**Vergangenes nachholen** — **Einstellungen → Einrichten → Daten importieren
|
||||
aus Home Assistant** leitet Fahrten, Tankvorgänge und Spannungswerte
|
||||
rückwirkend aus dem bereits aufgezeichneten Verlauf ab.
|
||||
|
||||
## Was die Integration mitbringt
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Panel | Sidebar-Eintrag „Mein Audi", von der Integration selbst angemeldet |
|
||||
| Entitäten | `sensor.audi_dashboard_*` — Profil, Fahrten, Tankvorgänge, Fahrzeugstatus, Batterieverlauf, Reifenzähler |
|
||||
| Dienste | `audi_dashboard.*` — 18 Stück, in Entwicklerwerkzeuge → Aktionen dokumentiert |
|
||||
| Daten | `/config/audi_dashboard/` — JSON und JSON Lines, dauerhaft, nie von einem Update angefasst |
|
||||
| Abhängigkeit | `pypdf` (für Tankbelege), von Home Assistant automatisch nachinstalliert |
|
||||
|
||||
Die Nutzlast der Entitäten steckt im Attribut `daten` und ist ausdrücklich von
|
||||
der Aufzeichnung ausgenommen — das Fahrtenarchiv wächst über Jahre auf
|
||||
Hunderte Kilobyte und hat in der Recorder-Datenbank nichts verloren.
|
||||
|
||||
## Companion-App (iOS)
|
||||
|
||||
`companion-app/` ist dieselbe Oberfläche als eigenständige App (React +
|
||||
Capacitor), die über die HA-API auf dieselben Entitäten und Dienste zugreift.
|
||||
Sie trägt ihre Version fest einkompiliert und meldet selbst, wenn sie älter
|
||||
ist als die installierte Integration — siehe
|
||||
[`VERSIONIERUNG.md`](VERSIONIERUNG.md).
|
||||
|
||||
## Ordner in diesem Repository
|
||||
|
||||
| Ordner | Inhalt |
|
||||
|---|---|
|
||||
| `custom_components/audi_dashboard/` | die Integration — Backend und Panel-Dateien |
|
||||
| `companion-app/` | die iOS-App |
|
||||
| `design-system/` | gemeinsame UI-Bausteine (`@audi-dash/ui`) |
|
||||
| `homeassistant/` | Installationspaket, Konfigurationsschnipsel, Anleitungen |
|
||||
| `testumgebung/` | Wegwerf-Home-Assistant in Docker für Entwicklung und Tests |
|
||||
| `tests/belegparser/` | Regressionstest des Tankbeleg-Lesers |
|
||||
@@ -0,0 +1,419 @@
|
||||
# Review: Branch `main`, Commits `c66ed82..d5562c7`
|
||||
|
||||
**Datum:** 2026-08-13 · **Umfang:** 18 Commits, 24 Dateien, +2752/−259 Zeilen
|
||||
**Gegenstand:** Setup-Menü zur Sensor-Zuordnung, Umstellung von WLAN/VAG auf FMM003,
|
||||
Standort-Kachel mit Live-Position, iOS-/Großbildschirm-Optikauflage, DuckDNS und Zertifikate.
|
||||
|
||||
Geprüft wurde gegen eine Arbeitskopie von `origin/main`. Die mit **[belegt]** markierten Befunde
|
||||
sind am Code nachvollzogen oder nachgestellt; **[plausibel]** heißt: aus dem Code abgeleitet, aber
|
||||
nicht im Browser reproduziert.
|
||||
|
||||
---
|
||||
|
||||
> ## ✅ Umgesetzt am 2026-08-13
|
||||
>
|
||||
> Alle Befunde dieses Dokuments sind behoben — siehe die fünf Commits von
|
||||
> `Setup-Zuordnung: Zuruecksetzen wirkt…` bis `Kleinere Befunde und Dokumentation…`.
|
||||
> Das Dokument bleibt als Befundprotokoll stehen: es begründet, warum die
|
||||
> Änderungen so aussehen, wie sie aussehen.
|
||||
>
|
||||
> **Zwei Punkte wurden bewusst anders gelöst als hier vorgeschlagen:**
|
||||
>
|
||||
> 1. *Karte nur bei echtem Ansichtswechsel neu aufbauen* — nicht umgesetzt. `render()` ersetzt den
|
||||
> Inhalt per `innerHTML`; eine überlebende Leaflet-Instanz zeigte danach auf ein abgehängtes
|
||||
> Element und bliebe leer. Der Neuaufbau hängt am 20-Sekunden-Takt des Backends und ließe sich
|
||||
> nur durch einen Umbau der Render-Architektur ändern. Die Begründung steht im Code.
|
||||
> Behoben ist dagegen die Standortabfrage, die tatsächlich ungedrosselt lief.
|
||||
> 2. *`SPECIFICATION.md`* wurde nicht in Teilen umgeschrieben, sondern hat einen datierten
|
||||
> Statushinweis am Anfang bekommen. Ein Umschreiben der §4/§5-Tabellen hätte neue
|
||||
> Ungenauigkeiten riskiert.
|
||||
>
|
||||
> **Drei Fehler kamen beim Umsetzen dazu**, sichtbar erst im laufenden Panel: ein unlesbares Datum
|
||||
> ergab „NaN/N" statt eines Strichs, ein fehlender Tanksensor „0 %" statt „unbekannt", und neben dem
|
||||
> Typenschild stand die Ausführung doppelt. Alle drei sind mit behoben.
|
||||
>
|
||||
> Und ein pyscript-Fallstrick ist dabei aufgefallen und im Code vermerkt: **Generatorausdrücke sind
|
||||
> nicht implementiert** (`not implemented ast ast_generatorexp`), Mengen-, Listen- und
|
||||
> Dict-Comprehensions dagegen schon.
|
||||
|
||||
---
|
||||
|
||||
## Gesamteindruck
|
||||
|
||||
Handwerklich stark. Das Setup-Menü ersetzt „vor der Installation `einstellungen.py` editieren"
|
||||
durch eine Oberfläche mit Live-Wertvorschau, Unavailable-Warnung, Duplikat-Prüfung und einem
|
||||
ehrlichen Neustart-Hinweis für die drei triggergebundenen Felder. Der Kniff dahinter — `setattr`
|
||||
auf dem laufenden Modul-Objekt, weil alle Leser `einstellungen.KM_SENSOR` als lebenden
|
||||
Attributzugriff machen — ist elegant und im Kopfkommentar sauber begründet.
|
||||
|
||||
Positiv außerdem: Escaping ist im neuen Code durchgängig (kein XSS gefunden), Karten-Ebenen werden
|
||||
vor dem Neuaufbau entfernt, die Haversine-Distanz ist mathematisch korrekt, Listenfelder sind gegen
|
||||
zu kurze Arrays abgesichert, und die Python-Syntax aller acht geänderten pyscript-Dateien ist in
|
||||
Ordnung.
|
||||
|
||||
Die Fehler unten liegen fast alle im selben Bereich — dem neuen Setup-Menü — und **drei davon
|
||||
werden erst nach einem Neustart sichtbar**, also mit maximalem zeitlichem Abstand zur Ursache.
|
||||
|
||||
## Übersicht
|
||||
|
||||
| # | Befund | Wirkung | Ort |
|
||||
|---|---|---|---|
|
||||
| 1 | Speichern bei fehlendem Katalog löscht alle Zuordnungen | Datenverlust | `audi-dashboard-app.js:1773, 2031` |
|
||||
| 2 | „Zurücksetzen" wirkt bei 15 von 17 Feldern nicht | falscher Zustand | `entitaeten.py:188` |
|
||||
| 3 | Alle vier Listenpositionen bekommen denselben Sensor | falsche Sicherheitsaussage | `audi-dashboard-app.js:1976` |
|
||||
| 4 | Vorschlagsschwelle akzeptiert beliebige Sensoren | falscher Fahrt-Trigger | `audi-dashboard-app.js:1980` |
|
||||
| 5 | Geleerte Listen setzen `["","","",""]` | Status dauerhaft „unbekannt" | `entitaeten.py:188` |
|
||||
| 6 | Zuordnung wird nirgends gesichert | Verlust bei Wiederherstellung | `backup.py:32` |
|
||||
| 7 | Kaputte `entitaeten.json` legt das Panel lahm | Totalausfall | `entitaeten.py:159` |
|
||||
| 8 | Standortabfrage im Dauerfeuer bei abgelehnter Freigabe | Akku, Netz | `audi-dashboard-app.js:553/557` |
|
||||
| 9 | Kein Tastaturweg in die Einstellungen unter 860 px | Bedienbarkeit | `audi-dashboard-app.js:3849` |
|
||||
| 10 | `.navmarke` nur in der iOS-Auflage versteckt | Tab-Leiste zerfällt | `audi-dashboard-ios.css:194` |
|
||||
|
||||
---
|
||||
|
||||
# Schwerwiegend
|
||||
|
||||
## 1. Speichern bei fehlendem Katalog löscht die gesamte Zuordnung [belegt]
|
||||
|
||||
**Kette:**
|
||||
|
||||
1. `ENTITAETEN` wird an genau einer Stelle gefüllt (`audi-dashboard-app.js:3723`), aus `hass.states`.
|
||||
2. Der Nachlade-Pfad nach einem HA-Neustart (`:3699-3703`) holt Profil, Fahrten, Tankvorgänge,
|
||||
Status und Batterieverlauf nach — **nicht** aber `pyscript.audi_dashboard_entitaeten`.
|
||||
3. `DATEN_GELADEN` hängt allein am Profil. Die App gilt also als geladen, während der Katalog fehlt.
|
||||
4. Der Knopf „Setup — Sensoren zuordnen" (`:1773`) ist nur an `einrichtenOffen` gekoppelt, nicht an
|
||||
den Katalog. `setupKatalog()` liefert `[]`, das Popup öffnet **ohne eine Zeile und ohne
|
||||
Fehlermeldung**, `setupZuordnung` bleibt `{}`.
|
||||
5. „Speichern" schickt `"{}"` (`:2031`). `overrides_schreiben()` ersetzt die Datei vollständig,
|
||||
ohne Zusammenführen (`entitaeten.py:166-173`).
|
||||
|
||||
**Auswirkung:** `data/entitaeten.json` enthält danach `{}`. Weil die `setattr`-Werte im laufenden
|
||||
Prozess bestehen bleiben, fällt der Verlust **erst beim nächsten Neustart** auf. Zusammen mit
|
||||
Befund 6 (keine Sicherung) gibt es dann nichts zurückzuholen.
|
||||
|
||||
**Vorschlag:** Setup-Knopf sperren, solange `ENTITAETEN` null ist, und in
|
||||
`setupSpeichernAusfuehren()` abbrechen, wenn `setupZuordnung` leer ist. Zusätzlich die Entität in
|
||||
den Nachlade-Pfad aufnehmen.
|
||||
|
||||
## 2. „Zurücksetzen" wirkt bei 15 von 17 Feldern nicht [belegt, nachgestellt]
|
||||
|
||||
`entitaeten.py:188` überspringt leere Werte:
|
||||
|
||||
```python
|
||||
if wert in (None, "", []):
|
||||
continue
|
||||
```
|
||||
|
||||
Der Kopfkommentar derselben Datei beschreibt das Problem exakt richtig — `setattr` verändert das
|
||||
Modul dauerhaft, „Zurücksetzen" muss den Standardwert *aktiv* zurückschreiben. Das Frontend tut das
|
||||
auch (`setupStandardwert`). Nur ist der Standardwert bei 15 der 17 Katalogfelder `""` oder `[]` —
|
||||
und genau die werden übersprungen.
|
||||
|
||||
Nachgestellt:
|
||||
|
||||
```
|
||||
nach Zuordnung: KM_SENSOR = 'sensor.alt_vag_mileage'
|
||||
nach Zurücksetzen: KM_SENSOR = 'sensor.alt_vag_mileage' ← erwartet: ''
|
||||
```
|
||||
|
||||
**Auswirkung:** Für den Nutzer sieht es aus, als hätte das Speichern versagt — `aktueller_stand()`
|
||||
liest aus dem laufenden Modul, das Feld zeigt beim nächsten Zeichnen wieder den alten Wert.
|
||||
Funktioniert nur bei `ZUENDUNG_SENSOR`, `BATTERIE_SENSOR` und `STANDORT_TRACKER`, den drei Feldern
|
||||
mit nicht-leerem Standard.
|
||||
|
||||
## 3. Alle vier Listenpositionen bekommen denselben Sensor [belegt]
|
||||
|
||||
`audi-dashboard-app.js:1976-1981`:
|
||||
|
||||
```js
|
||||
zuordnung[feld.key] = feld.positionen.map((_, i) => {
|
||||
const vorhanden = (werte[feld.key] || [])[i];
|
||||
if (vorhanden) return vorhanden;
|
||||
const vorschlag = entitaetKandidaten(feld.key, "", "")[0]; // i geht nicht ein
|
||||
return vorschlag && entitaetScore(feld, vorschlag, "") >= 3 ? vorschlag.id : "";
|
||||
});
|
||||
```
|
||||
|
||||
`entitaetKandidaten()` hängt nicht vom Index ab und schließt bereits vergebene IDs nicht aus —
|
||||
alle vier Positionen bekommen dieselbe Entität. Betrifft `TUER_SENSOREN`, `FENSTER_SENSOREN`,
|
||||
`TUERSCHLOSS_SENSOREN`.
|
||||
|
||||
**Auswirkung:** Bei einer Frischinstallation (genau der Fall, für den das Menü gebaut wurde) prüft
|
||||
`_sicherheitscheck()` danach viermal dieselbe Tür. **„Sicher abgestellt" meldet gesichert, obwohl
|
||||
drei Türen nie geprüft wurden.** Die Duplikat-Warnung fängt das beim Speichern ab, ist aber mit
|
||||
„Trotzdem speichern" wegklickbar.
|
||||
|
||||
## 4. Die Vorschlagsschwelle akzeptiert beliebige Sensoren [belegt]
|
||||
|
||||
`entitaetScore()` (`:1936`) vergibt: Stichwort +5, Domain +3, device_class +3, Einheit +2. Die
|
||||
Schwelle ist `>= 3` — **ein Domain-Treffer allein genügt**, ohne dass ein einziges Stichwort passt.
|
||||
Verschärfend: `setupNurPassend` wird zwei Zeilen vorher auf `true` gesetzt (`:1971`), wodurch
|
||||
`entitaetKandidaten()` ohnehin hart auf die Domain filtert. Die Schwelle ist damit *immer* erfüllt.
|
||||
Die Reihenfolge kommt aus `Object.keys(HASS.states)`, ist also faktisch willkürlich.
|
||||
|
||||
**Auswirkung:** Ohne passenden Zündungssensor wird `ZUENDUNG_SENSOR` stumm mit dem erstbesten
|
||||
`binary_sensor.*` vorbelegt — Bewegungsmelder, Fensterkontakt, was zuerst kommt. Das Feld sieht
|
||||
ausgefüllt aus, wird mitgespeichert, und **die gesamte Fahrterkennung hängt an einem
|
||||
Zufallssensor**. Bei Einzelfeldern greift keine Duplikat-Warnung. Dasselbe für `TANK_SENSOR` (jeder
|
||||
%-Sensor, etwa Luftfeuchte) und `REFRESH_BUTTON` (jedes `button.*`).
|
||||
|
||||
**Vorschlag:** Schwelle auf `>= 5` — also mindestens ein Stichworttreffer.
|
||||
|
||||
## 5. Geleerte Listenfelder setzen `["","","",""]` [belegt]
|
||||
|
||||
Die Skip-Regel aus Befund 2 prüft auf `[]`. Ein Array aus vier leeren Zeichenketten ist das nicht:
|
||||
|
||||
```python
|
||||
["","","",""] in (None, "", []) # False -> wird per setattr gesetzt
|
||||
```
|
||||
|
||||
`_sicherheitscheck()` zippt dann über vier leere Entity-IDs und erzeugt vier „unbekannt"-Zeilen —
|
||||
mal drei Listen sind das zwölf.
|
||||
|
||||
**Auswirkung:** Weil die Gesamtaussage bewusst `None` wird, sobald *eine* Prüfung unbekannt ist,
|
||||
steht die Übersicht danach **dauerhaft auf „Zustand unbekannt"** statt auf grün.
|
||||
|
||||
Kurios ist die Kombination mit Befund 2: Bei Einzelfeldern wirkt „Zurücksetzen" gar nicht, bei
|
||||
Listenfeldern zu stark. Beides verschwindet mit demselben Fix — statt leere Werte zu überspringen,
|
||||
immer den Standardwert aus `_STANDARDWERTE` zurückschreiben:
|
||||
|
||||
```python
|
||||
def overrides_anwenden():
|
||||
overrides = overrides_lesen()
|
||||
for key in _SCHLUESSEL:
|
||||
wert = overrides.get(key)
|
||||
leer = wert in (None, "", []) or (isinstance(wert, list) and not any(wert))
|
||||
setattr(einstellungen, key, _STANDARDWERTE[key] if leer else wert)
|
||||
```
|
||||
|
||||
## 6. Die Sensor-Zuordnung wird nirgends gesichert [belegt]
|
||||
|
||||
`backup.py:32` sichert `fahrzeugprofil.json`, `fahrten.jsonl`, `tankvorgaenge.jsonl` —
|
||||
`entitaeten.json` fehlt. Auch `audi_dashboard_backup_wiederherstellen` kennt es nicht, `update.ps1`
|
||||
fasst `data/` bewusst nicht an, und in `homeassistant/.gitignore` steht es nicht neben den anderen
|
||||
Laufzeitdateien.
|
||||
|
||||
**Auswirkung:** Nach einer Wiederherstellung ist die komplette Sensor-Zuordnung still weg. In
|
||||
Verbindung mit Befund 1 gibt es keinen Rückweg.
|
||||
|
||||
## 7. Eine beschädigte `entitaeten.json` legt das Panel lahm [belegt]
|
||||
|
||||
`overrides_lesen()` (`entitaeten.py:159`) ruft `json.loads` ohne Absicherung. Der Aufruf steht in
|
||||
`beim_start()` **vor** `alles_veroeffentlichen()` — wirft er, wird gar nichts veröffentlicht, das
|
||||
Panel bleibt komplett leer.
|
||||
|
||||
Geschrieben wird zwar atomar über `.tmp` + `os.replace`, das Risiko ist also gering — der Ausfall
|
||||
wäre aber total. Dieselbe Fehlerklasse, die im Branch `umsetzung-datametric360` bei `profil_lesen()`
|
||||
bereits abgefangen wurde.
|
||||
|
||||
---
|
||||
|
||||
# Mittel
|
||||
|
||||
## 8. Standortabfrage im Dauerfeuer, Karte bei jedem Render neu [belegt]
|
||||
|
||||
`USER_POS_TS` wird nur im Erfolgs-Callback gesetzt (`:553`), nicht im Fehlerfall (`:557`). Die
|
||||
30-Sekunden-Drossel in `standortErfassenFallsNoetig()` (`:560`) greift damit nie, wenn der Nutzer
|
||||
die Standortfreigabe ablehnt oder kein Fix zustande kommt.
|
||||
|
||||
Parallel dazu zerstört `render()` die Vorschaukarte und baut sie neu (`:2879-2880`), sobald die
|
||||
Route `home` ist — bei **jedem** Backend-Update.
|
||||
|
||||
**Auswirkung:** Mit einem GPS-Tracker als Quelle (genau der FMM003-Fall) sind das dutzende
|
||||
`getCurrentPosition({enableHighAccuracy:true})`-Anfragen pro Stunde plus sichtbares Kachelflackern.
|
||||
|
||||
**Vorschlag:** `USER_POS_TS = Date.now()` auch im Fehlerpfad; Karte nur bei echtem Ansichtswechsel
|
||||
neu bauen — die Variable `gleicheAnsicht` gibt es in `render()` bereits.
|
||||
|
||||
## 9. Kein Tastaturweg in die Einstellungen unter 860 px [belegt]
|
||||
|
||||
`audi-dashboard-ios.css:81` versteckt `.profilbtn` — den echten `<button aria-label="Einstellungen">` —
|
||||
und holt ihn erst in der Container-Abfrage ab 860 px zurück (`:230`). Der Ersatz ist ein
|
||||
Klick-Handler auf `#marke` (`:2965`), und das ist ein `<div class="rings" aria-label="Audi">`
|
||||
(`:3849`): ohne `role`, ohne `tabindex`.
|
||||
|
||||
**Auswirkung:** In Telefonbreite gibt es für Tastatur- und Screenreader-Nutzer **keinen** Zugang zu
|
||||
den Einstellungen. Ein `<div>` mit `aria-label` ohne Rolle wird von den meisten Screenreadern nicht
|
||||
einmal angesagt. Gleiche Fehlerklasse wie das Swipe-Löschen aus `AUDIT_2026-08-10.md`.
|
||||
|
||||
**Vorschlag:** `#marke` zu einem `<button aria-label="Einstellungen">` machen; dann kann auch
|
||||
`pointer-events:auto` in `audi-dashboard-ios.css:78` entfallen.
|
||||
|
||||
## 10. `.navmarke` nur in der iOS-Auflage versteckt [belegt]
|
||||
|
||||
`audi-dashboard-app.js:2956-2963` hängt den Ringklon **unbedingt** in `#tabbar`.
|
||||
`.navmarke{display:none}` steht ausschließlich in `audi-dashboard-ios.css:194`. `.tabbar` ist ein
|
||||
`display:grid` mit `repeat(5, 1fr)` (`audi-dashboard.css:211`).
|
||||
|
||||
**Auswirkung:** Lädt `/local/audi-dashboard-ios.css` nicht (404, Cache), wird der Klon ein sechstes
|
||||
Grid-Element — alle Tabs rutschen eine Spalte weiter, „Tanken" fällt in eine zweite Zeile, die
|
||||
Menüleiste zerfällt. Kein theoretischer Fall: Commit `3548f77` dokumentiert genau dieses
|
||||
Deployment-Loch.
|
||||
|
||||
**Vorschlag:** `.navmarke{display:none}` gehört in die Basis-CSS, die Sichtbarkeit in die Auflage.
|
||||
|
||||
## 11. Standort-Menü ohne `pointercancel` [belegt]
|
||||
|
||||
`standortMenuVerdrahten()` (`:706-731`) setzt `smY0` nur in `pointerup` zurück. Alle drei anderen
|
||||
Zeigergesten der Datei haben einen `pointercancel`-Handler (`:2929`, `:3055`, `:3122`), diese nicht.
|
||||
|
||||
**Auswirkung:** Übernimmt das System die Berührung (iOS-Randgeste, Multitouch, eingehender Anruf),
|
||||
bleibt `smY0` gesetzt. Der `pointermove`-Handler ruft dann `preventDefault()` für jede weitere
|
||||
Bewegung — **Scrollen ist in der ganzen App blockiert**, bis irgendwo ein `pointerup` kommt, der
|
||||
dann ein `dy` aus einem veralteten Startpunkt auswertet.
|
||||
|
||||
## 12. Fehlgeschlagenes Reverse-Geocoding wird dauerhaft gecacht [belegt]
|
||||
|
||||
`:572-586` setzt `STANDORT_ADRESSE_KEY` auch im `catch`-Zweig.
|
||||
|
||||
**Auswirkung:** Ein einziger fehlgeschlagener Nominatim-Abruf (Rate-Limit, kurzer Netzausfall) für
|
||||
eine Zelle führt dazu, dass für dieselbe Zelle die **gesamte Sitzung** lang nur Koordinaten
|
||||
angezeigt werden, auch wenn das Netz längst wieder da ist.
|
||||
|
||||
**Vorschlag:** Key nur im Erfolgsfall setzen.
|
||||
|
||||
## 13. Setup-Popup hält den `role="dialog"`-Vertrag nicht ein [belegt]
|
||||
|
||||
`:2774` deklariert `role="dialog" aria-modal="true"`. Es fehlen: Escape zum Schließen (in der Datei
|
||||
gibt es keinen `keydown`-Handler), eine Fokusfalle (Tab wandert in den überdeckten Hintergrund),
|
||||
und der Schalter „Nur passende Sensoren anzeigen" (`:2779`) hat keinen zugänglichen Namen — der
|
||||
Beschriftungstext steht im Geschwister-`<span>` außerhalb des `<label>`.
|
||||
|
||||
Dazu: Die Entitäts-Auswahl öffnet ausschließlich im `click`-Handler (`:3181`). Wer per Tab ins Feld
|
||||
springt und tippt, löst zwar den `input`-Handler aus, der bricht aber ab, weil `setupSucheOffen`
|
||||
noch `null` ist — sichtbar passiert nichts. `role="combobox"`, `aria-expanded` und `aria-controls`
|
||||
fehlen ebenfalls.
|
||||
|
||||
## 14. Standort-Menü nicht per Tastatur bedienbar [belegt]
|
||||
|
||||
Im eingeklappten Zustand steht das Blatt per `transform` überwiegend außerhalb des Sichtbereichs,
|
||||
bleibt aber vollständig im DOM und im Tab-Fokus — Adresse, Route-Link und Teilen-Knopf sind
|
||||
fokussierbar, ohne sichtbar zu sein (kein `inert`, kein `aria-hidden`). Öffnen geht gar nicht per
|
||||
Tastatur: `.standortmenu-griffzone` ist ein `<div>` mit reinen Pointer-Handlern. Der Umschalter
|
||||
Straße/Satellit (`:773`) hat kein `aria-pressed`, sein Label bleibt in beiden Zuständen gleich.
|
||||
|
||||
## 15. `disconnectedCallback()` räumt nur eine von drei Karten ab [belegt]
|
||||
|
||||
`:3815-3817` zerstört `MAP`, nicht aber `SMAP`/`TMAP`. Wird das Panel abgehängt, während Standort-
|
||||
oder Übersichtsseite offen ist, bleibt eine Leaflet-Instanz samt Resize-Listener und
|
||||
Kachel-Abrufen am Leben.
|
||||
|
||||
---
|
||||
|
||||
# Klein
|
||||
|
||||
| Ort | Befund |
|
||||
|---|---|
|
||||
| `audi-dashboard-ios.css:88, 208` | `main{padding:…}` (0,0,1) verliert gegen `main#view` (1,0,1) in `audi-dashboard.css:197`. Auf Großbildschirmen bleibt der Seitenrand bei 20 px statt der beabsichtigten 44 px. **Achtung:** `.standort-vollbild{margin:0 -20px}` (`audi-dashboard.css:704`) passt heute genau dazu — beides gehört zusammen angefasst. |
|
||||
| `audi-dashboard-app.js:3190/3612` | „Nur passende Sensoren anzeigen" wirkt nicht, solange ein Dropdown offen ist: Der Klick-Handler ersetzt das Overlay-DOM, bevor das `change`-Event des Kontrollkästchens ausgeliefert wird. [plausibel] |
|
||||
| `audi-dashboard-app.js:2966` | Theme-Umschalten ruft kein `render()` — die Leaflet-Kacheln bleiben bis zum nächsten Backend-Update im alten Stil. Durch die neue Dauerkarte auf der Übersicht deutlich sichtbarer als früher. |
|
||||
| `audi-dashboard-app.js:804` | `${de(CAR.tankPct)} %` zeigt ohne Tanksensor „0 %“, während direkt daneben korrekt „Reichweite unbekannt“ steht. |
|
||||
| `audi-dashboard-app.js:496, 518, 117` | Toter Code: `fahrzeugGlyphPfade()` nie aufgerufen, `zustand.parkplatz` nie gelesen, `standortGenauigkeitM`/`standortZeit` gemappt aber ungenutzt. Kommentare behaupten das Gegenteil. |
|
||||
| `audi-dashboard-app.js:595` | Einzige Interpolation im neuen Code, die ohne `esc()` in ein Attribut geht. Heute nicht ausnutzbar, weil `_zu_zahl()` im Backend nur Zahlen durchlässt — als Härtung trotzdem sinnvoll, ebenso `Number.isFinite` in `standortBekannt()`. |
|
||||
|
||||
---
|
||||
|
||||
# Dokumentation
|
||||
|
||||
## AGENTS.md widerspricht sich an sechs Stellen [belegt]
|
||||
|
||||
Die Datei ist auf 427 Zeilen gewachsen; neue Einträge wurden angehängt statt eingearbeitet.
|
||||
|
||||
| Stelle | Aussage | Widerspruch |
|
||||
|---|---|---|
|
||||
| Z. 94 | „trip detection via the iPhone WLAN sensor" | Z. 249 ff.: läuft über `ZUENDUNG_SENSOR`, WLAN „fully removed" |
|
||||
| Z. 110 | „Codec JSON → MQTT/TLS → Mosquitto" | Z. 227 ff.: Schwenk auf flespi, „not MQTT at all" |
|
||||
| Z. 93 | „4 modules" | tatsächlich 5 (`entitaeten.py` neu) |
|
||||
| Z. 97 | „3.130 lines" | tatsächlich 3.873 |
|
||||
| Z. 100 | „`einstellungen.py` — the one file edited before install" | genau das ersetzt jetzt das Setup-Menü |
|
||||
| Z. 320 | `STANDORT_TRACKER` „left empty" | steht auf `device_tracker.testzone_fmm003` |
|
||||
|
||||
Dazu veraltete Codeverweise: `fakeTrack()` liegt bei `:421` statt `:414`, die Statistik-Behauptung
|
||||
in `INSTALL.md` bei Z. 229 statt Z. 204.
|
||||
|
||||
Die Datei verlangt selbst „nicht festhalten, was Code und Git-History schon zeigen" (Z. 47) —
|
||||
Z. 282–401 sind 120 Zeilen Changelog-Prosa für fünf Einträge, und Z. 249–279 dupliziert inhaltlich
|
||||
Z. 133–153.
|
||||
|
||||
## INSTALL.md [belegt]
|
||||
|
||||
- **Zirkuläre Schrittfolge:** Schritt 4 verweist auf „siehe Schritt 9 zuerst" (Z. 96), Schritt 7
|
||||
setzt Schritt 4 voraus (Z. 176). Die Reihenfolge 4→9→7→4 wird nirgends aufgelöst.
|
||||
- **Ausgelieferte Default-Entities werden verschwiegen:** `ZUENDUNG_SENSOR`, `BATTERIE_SENSOR` und
|
||||
`STANDORT_TRACKER` sind mit den `testzone_fmm003`-Entities der Autoren-Instanz vorbelegt. Bei
|
||||
einer Frischinstallation zeigen sie ins Leere. Z. 108–110 behauptet nur für KM/TANK/RANGE
|
||||
„unbelegt".
|
||||
- **Override-Datei nicht benannt:** Z. 104 spricht von „einer Override-Datei" — der Pfad
|
||||
`/config/audi_dashboard/entitaeten.json` steht nirgends, und ihre fehlende Sicherung (Befund 6)
|
||||
ist nicht erwähnt.
|
||||
- Schritt 2 verweist auf `fahrzeugprofil.example.json`, deren Z. 2 weiterhin den „WLAN-Namen"
|
||||
verlangt, obwohl `fahrzeug.wlan_name` im selben Branch entfernt wurde.
|
||||
|
||||
## WLAN-Reste in der übrigen Doku [belegt]
|
||||
|
||||
Im Code ist die Entfernung vollständig (keine Fundstelle). In der Doku nicht:
|
||||
`SPECIFICATION.md` (Z. 16, 147, 181, 196, 217), `homeassistant/README.md:106` und die Profilvorlage
|
||||
nennen WLAN-Sensor und `TommiG1/HA_VAG-EU-Data-Act` weiterhin als aktuellen Stand.
|
||||
|
||||
## `update.ps1` [belegt]
|
||||
|
||||
Für `www/` ist das Skript nach dem Fix **vollständig** — `audi-dashboard-ios.css` und `badges/`
|
||||
sind ergänzt, Cache-Busting greift auch für die neue CSS-Datei.
|
||||
|
||||
**Verbleibende Lücke:** `homeassistant/data/shell_beleg_parser.py` ist Code, kein Datenbestand —
|
||||
`belegverarbeitung.py:41` lädt ihn aus `/config/audi_dashboard/`. `update.ps1` schließt `data/`
|
||||
pauschal aus, `INSTALL.md` behandelt ihn als Einmal-Kopie. **Änderungen am Beleg-Parser, dem
|
||||
einzigen getesteten Teil des Repos, kämen auf keiner Instanz an.**
|
||||
|
||||
## `design/` [belegt]
|
||||
|
||||
`dm360-host.html` lädt `audi-dashboard-app.js` (Z. 12) und `audi-dashboard-ios.css` (Z. 33) — beide
|
||||
existieren in `design/` nicht. Aus der Repo-Kopie ist das Board funktionslos; es läuft nur im
|
||||
Claude-Design-Projekt. Nirgends dokumentiert.
|
||||
|
||||
`design/README.md:19` führt `audi-dashboard-ios.css` in der Inhaltstabelle des Ordners (die Datei
|
||||
liegt in `homeassistant/www/`) und behauptet, die Auflage „rührt `audi-dashboard-app.js` nicht an" —
|
||||
Z. 57–59 derselben Datei beschreibt, dass genau dort der Stylesheet-Loader ergänzt wurde.
|
||||
|
||||
**Markenassets:** Der Diff fügt keine neuen Binärdateien hinzu. `DataMetric360 Board.dc.html`
|
||||
verwendet die vorhandenen Audi-Type-Dateien per `@font-face` — das Lizenzrisiko bleibt unverändert,
|
||||
nicht erhöht.
|
||||
|
||||
## `.gitignore` [belegt]
|
||||
|
||||
`installationspaket/` ist korrekt ergänzt und schließt nichts Getracktes aus. Es fehlt
|
||||
`data/entitaeten.json` neben den anderen drei Laufzeitdateien — heute harmlos, weil die Datei nur
|
||||
unter `/config/audi_dashboard/` entsteht, aber inkonsistent zum eigenen Grundsatz der Datei.
|
||||
|
||||
---
|
||||
|
||||
# Was den Branch `umsetzung-datametric360` betrifft
|
||||
|
||||
**Der flespi-Schwenk macht `homeassistant/FMM003_MAPPING.md` überholt.** Das Dokument beschreibt
|
||||
Codec JSON über MQTT/TLS mit eigener CA und einem `mosquitto_sub`-Mitschnitt. Mit flespi läuft es
|
||||
über den nativen Codec8/TCP-Kanal mit IMEI-Authentifizierung, ohne Zertifikate und ohne eingehenden
|
||||
Port; die Feldzuordnung entstünde aus der flespi-REST-API. Beim Zusammenführen anpassen oder
|
||||
ersetzen.
|
||||
|
||||
**Das CORS-Rätsel ist damit auch erklärt.** Die Notiz auf `main`, der `http:`-Block sei aus der
|
||||
`configuration.yaml` entfernt worden, „nachdem HA angefangen hat, ihn zu migrieren beziehungsweise
|
||||
zu ignorieren", erklärt, warum `cors_allowed_origins` in HA 2026.8 wirkungslos blieb — auch bei
|
||||
gleichem Ursprung. Gehört in `homeassistant/REVERSE_PROXY.md` nachgetragen.
|
||||
|
||||
**Die Falle beim Zusammenführen** steht bereits in `AGENTS.md` dieses Branches: `profil_lesen()`
|
||||
gibt hier bei fehlender Profildatei `None` zurück, `profil.py` ist auf `main` unverändert und geht
|
||||
konfliktfrei durch — die dortigen Aufrufstellen prüfen das aber nicht.
|
||||
|
||||
---
|
||||
|
||||
# Empfehlung
|
||||
|
||||
Reihenfolge nach Schaden:
|
||||
|
||||
1. **Befund 1** (Datenverlust) — zwei Riegel, wenige Zeilen.
|
||||
2. **Befunde 2 und 5** (Zurücksetzen) — ein gemeinsamer Fix in `overrides_anwenden()`.
|
||||
3. **Befunde 3 und 4** (falsche Vorbelegung) — Index in den Vorschlag einbeziehen, Schwelle auf 5.
|
||||
4. **Befund 6** (Sicherung) — eine Zeile in `backup.py`, plus Wiederherstellung.
|
||||
5. **Befund 7** (Absicherung beim Lesen), **8** (Akku), **9/10** (Bedienbarkeit, Robustheit).
|
||||
|
||||
Die Punkte 1 bis 4 betreffen alle das neue Setup-Menü und lassen sich in einem Zug erledigen.
|
||||
@@ -1,5 +1,21 @@
|
||||
# Audi Dashboard — Technical Specification
|
||||
|
||||
> ## ⚠️ Teilweise überholt (Stand 2026-08-13)
|
||||
>
|
||||
> Dieses Dokument beschreibt den Stand **vor** der FMM003-Umstellung. Drei Dinge haben sich seither
|
||||
> geändert und gelten in diesem Dokument durchgehend als veraltet:
|
||||
>
|
||||
> 1. **Fahrterkennung** läuft über den Zündungssensor des FMM003, nicht mehr über den
|
||||
> iPhone-WLAN-Sensor. `sensor.iphone_wifi_connection` und `fahrzeug.wlan_name` sind entfernt.
|
||||
> 2. **Datenquelle** ist der FMM003 über flespi (Codec8/TCP), nicht mehr die HACS-Integration
|
||||
> `TommiG1/HA_VAG-EU-Data-Act`.
|
||||
> 3. **Entity-IDs** werden im Setup-Menü der App zugeordnet (Einstellungen → Fahrzeug einrichten →
|
||||
> Setup) und in `data/entitaeten.json` abgelegt. `einstellungen.py` enthält nur noch die
|
||||
> eingebauten Vorgabewerte und ist keine Pflicht-Bearbeitung mehr vor der Installation.
|
||||
>
|
||||
> Alles Übrige — Datenmodell, Geschäftsregeln, Frontend-Aufbau, §7 „Known Gaps" — gilt weiterhin.
|
||||
> Den aktuellen Stand führt `AGENTS.md`, Abschnitt B.
|
||||
|
||||
**Audience:** an AI coding agent picking up this codebase with no prior context.
|
||||
**Scope:** the Home Assistant panel (`homeassistant/`) — the finished, in-use app. A separate sibling project, `design-system/`, is a standalone React component-library replica of this app's visual language, built solely to feed Claude Design (`claude.ai/design`) via the `/design-sync` skill; it is **not** part of this app and deliberately excludes Audi brand assets. `COMPANION_APP_ARCHITECTURE.md` documents a third, **not-yet-built** sibling project — an iOS/Android/HA-iframe companion app fed by a Teltonika FMM003 tracker instead of the WLAN-sensor/VAG-integration data sources described below; its design is decided but no code exists yet. Don't confuse any of the three.
|
||||
|
||||
@@ -25,33 +41,51 @@ A private, single-user Home Assistant `panel_custom` dashboard that replaces the
|
||||
|
||||
## 2. Concept & Design System
|
||||
|
||||
### Visual language
|
||||
Dark ("Nacht") is the default and primary theme; a light ("Tag") theme exists as a toggle. No shadows, no gradients anywhere except the fade under the vehicle photo (`.szene`). Flat tiles, pill-shaped controls, hairline dividers.
|
||||
> **iOS-Optik ist seit 2026-08-13 die verbindliche Linie** (Entscheidung nach
|
||||
> `DESIGN_REVIEW_2026-08-13.md` §A, Option 1: „iOS-Optik als neue verbindliche Linie
|
||||
> dokumentieren"). Das ursprüngliche, reine Audi-CI-Design unten war bis dahin gültig und bleibt
|
||||
> wortgleich archiviert in [`AUDI_CI_ARCHIV_2026-08-13.md`](AUDI_CI_ARCHIV_2026-08-13.md) —
|
||||
> inklusive einer kurzen Anleitung, wie man technisch zurückwechselt (die Auflage ist rein additiv,
|
||||
> die Basis-Werte unten sind im Code nach wie vor vorhanden). `bauauftrag.md` §8 beschreibt weiterhin
|
||||
> das alte Design und gilt ab hier als überholt, wie es die Projektkonvention für Entscheidungen
|
||||
> vorsieht (siehe `AGENTS.md`).
|
||||
>
|
||||
> Bekannter, noch offener Folgefehler aus demselben Review: die Rot-Semantik ist in der iOS-Auflage
|
||||
> invertiert (`.aktion` füllt jede Primäraktion rot statt nur destruktive) — Befund B, noch nicht
|
||||
> behoben.
|
||||
|
||||
### Colors (CSS custom properties, defined per-theme via `:root` / `[data-theme="tag"]`)
|
||||
### Visual language
|
||||
iOS-native Formensprache (`audi-dashboard-ios.css`, additive Auflage über der Audi-CI-Basis unten):
|
||||
große Titel, gruppierte 44-px-Zeilen, transluzente Tab-Leiste mit Blur, iOS-Schalter/-Segmente,
|
||||
gefüllte Aktionsknöpfe. Dunkel ("Nacht") bleibt Standard, "Tag" als Umschalter. Audi Type und das
|
||||
Rot `#F50537` bleiben führend (Typografie, Akzentfarbe) — Fläche, Radius und Signalfarben folgen
|
||||
iOS-Konventionen statt der ursprünglichen Audi-Palette.
|
||||
|
||||
### Colors (CSS custom properties, iOS-Auflage überschreibt die Audi-CI-Basis pro Theme)
|
||||
|
||||
| Role | Nacht (default) | Tag |
|
||||
|---|---|---|
|
||||
| `--canvas` (page background) | `#161b23` | `#FFFFFF` |
|
||||
| `--tile` | `#1f2733` | `#f2f2f2` |
|
||||
| `--tile-2` (inputs, hover) | `#2a3341` | `#e5e5e5` |
|
||||
| `--line` | `rgba(255,255,255,.10)` | `rgba(0,0,0,.10)` |
|
||||
| `--line-strong` | `rgba(255,255,255,.20)` | `rgba(0,0,0,.22)` |
|
||||
| `--canvas` (page background) | `#0C1014` | `#F2F2F7` |
|
||||
| `--tile` | `rgba(255,255,255,.055)` | `#FFFFFF` |
|
||||
| `--tile-2` | `rgba(255,255,255,.10)` | `#EFEFF4` |
|
||||
| `--line` | `rgba(255,255,255,.13)` | `rgba(60,60,67,.13)` |
|
||||
| `--line-strong` | `rgba(255,255,255,.24)` | `rgba(60,60,67,.24)` |
|
||||
| `--fg` | `#FFFFFF` | `#000000` |
|
||||
| `--fg2` (secondary text) | `#9aa1ad` | `#4c4c4c` |
|
||||
| `--fg3` (labels/eyebrows) | `#8a94a3` | `#666666` |
|
||||
| `--ok` | `#15da15` | `#0DA20D` |
|
||||
| `--warn` | `#ffaa00` | `#ffaa00` |
|
||||
| `--shade` | `rgba(255,255,255,.05)` | `rgba(0,0,0,.04)` |
|
||||
| `--bad` | `#fd2c4e` | `#eb0d3f` |
|
||||
| `--red` (accent, theme-independent) | `#F50537` | `#F50537` |
|
||||
| `--fg2` | `#BFC4CC` | `#3C3C43` |
|
||||
| `--fg3` | `#8E8E93` | `#8E8E93` |
|
||||
| `--ok` | `#30D158` | `#34C759` |
|
||||
| `--warn` | `#FFD60A` | `#FF9F0A` |
|
||||
| `--bad` | `#FF453A` | `#FF3B30` |
|
||||
| `--ios-tint` (Akzent/Aktionsfüllung, theme-unabhängig) | `#F50537` | `#F50537` |
|
||||
|
||||
`--red` is used sparingly (accents, active states, destructive actions), not as a background fill.
|
||||
`.aktion` füllt sich vollflächig mit `--ios-tint` — abweichend von der ursprünglichen Regel „Rot nur
|
||||
als Akzent" (bekannter, offener Befund, siehe Kasten oben).
|
||||
|
||||
### Radii
|
||||
- `--r-tile: 20px` — tiles, the vehicle-photo "scene" container.
|
||||
- `--r-pill: 999px` — buttons, switches, segmented controls, progress bars.
|
||||
- Small functional elements (form inputs, placeholder/mini image boxes, popups) use **hardcoded literal pixel values** (6px / 12px / 14px respectively) rather than a shared token — see §7 for why this is a discrepancy from the brief.
|
||||
- `--r-tile: 16px` (iOS-Wert; Audi-CI-Basis war 20px, siehe Archiv) — Kacheln, Popups, Bildbereiche.
|
||||
- `--r-pill: 999px` unverändert — Buttons, Schalter, Segmente, Fortschrittsbalken.
|
||||
- `.setup-popup`/`.standortmenu` schreiben weiterhin `border-radius: 20px` als Literal
|
||||
(Inkonsistenz zum 16px-Token, ebenfalls im Review dokumentiert, noch nicht bereinigt).
|
||||
|
||||
### Typography
|
||||
Three font-family names in play, all under the trademarked "Audi Type" family, embedded inline as base64 `woff2` directly in `audi-dashboard.css` (private-install only — see Licensing below):
|
||||
@@ -231,7 +265,7 @@ These are places the shipped code and the original build brief disagree — load
|
||||
## 8. File map
|
||||
|
||||
```
|
||||
Audi_app_TG/
|
||||
audi-app/
|
||||
├── bauauftrag.md original build brief (German, historical — see §7)
|
||||
├── dashboard-muster.html the original static prototype (superseded, kept for reference)
|
||||
├── SPECIFICATION.md this document
|
||||
|
||||
@@ -52,7 +52,7 @@ KI-Modell oder in einer frischen Session ohne Vorwissen abgearbeitet werden kann
|
||||
| 7 | Alle Screens umsetzen | 3, 6 | nein |
|
||||
| 8 | Audi-Assets einbauen | 7 | nein |
|
||||
| 9 | Tests (authentifiziert + Komponenten) | 6 | nein |
|
||||
| 10 | PWA + Capacitor/iOS-Sideload | 7 | Mac mit Xcode, iPhone |
|
||||
| 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** |
|
||||
@@ -416,10 +416,24 @@ nicht ein Riesencommit.
|
||||
|
||||
---
|
||||
|
||||
## Phase 10 — PWA und Capacitor 🟡 STUFE 1 ERLEDIGT (2026-08-11)
|
||||
## Phase 10 — PWA und Capacitor ✅ ERLEDIGT (2026-08-11)
|
||||
|
||||
> PWA fertig. Capacitor-Konfiguration, Secure-Storage-Adapter und die QR-Seite
|
||||
> liegen bereit; die Hülle selbst braucht einen Mac mit Xcode und ein Gerät.
|
||||
> 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.
|
||||
>
|
||||
> **2026-08-29: Schritt 7 erledigt.** Der Besitzer hat das bezahlte
|
||||
> Entwicklerkonto gelöst und das iPhone im Portal registriert; die Team-Kennung
|
||||
> blieb dabei `RMACS9VLS4` (die bezahlte Mitgliedschaft stuft das bestehende
|
||||
> Team hoch, sie legt kein neues an). Ergebnis: `companion-app/auslieferung/App.ipa`,
|
||||
> 2,4 MB, **Ad-hoc** signiert mit `Apple Distribution: Paul Nothaft`, Profil
|
||||
> gültig bis 29.08.2027. Zwei Skripte tragen das:
|
||||
> `scripts/ios-signieren.sh` (bauen und signieren) und `scripts/ios-luftweg.sh`
|
||||
> (Manifest und Installationsseite für die Übertragung über die Luft).
|
||||
> Offen bleibt allein ein HTTPS-Host, der die Dateien ausliefert — siehe
|
||||
> Schritt 8. Fallstricke und Diagnosen in `AGENTS.md` Abschnitt AI.
|
||||
|
||||
**Ziel:** Die App läuft auf dem iPhone. Zwei Stufen — erst PWA (sofort nutzbar), dann Capacitor
|
||||
(Keychain + QR-Scan).
|
||||
@@ -446,11 +460,27 @@ nicht ein Riesencommit.
|
||||
(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
|
||||
7. ✅ **iOS-Sideload (erledigt 2026-08-29):** `bash companion-app/scripts/ios-signieren.sh`
|
||||
baut Webbündel, synchronisiert die Hülle, archiviert signiert und exportiert die `.ipa`
|
||||
nach `companion-app/auslieferung/`. Die Team-Kennung steht im Skript, weil `ios/` gitignored
|
||||
ist und jede in Xcode geklickte Einstellung beim nächsten `npx cap add ios` verschwinden würde.
|
||||
Der Entscheidungspunkt Kostenlos-vs-Bezahlt ist entschieden: **bezahltes Konto**, damit
|
||||
Signatur ein Jahr gültig statt sieben Tage, und Geräteregistrierung über das Portal ohne
|
||||
angeschlossenes Telefon.
|
||||
⚠️ **Falle, die viel Zeit kostet:** beim ersten Signieren fragt der Schlüsselbund per Dialog
|
||||
um Erlaubnis. Wird der nicht beantwortet, hängt `xcodebuild` wortlos minutenlang und endet
|
||||
mit `errSecInternalComponent`. Im Dialog **„Immer erlauben"** wählen — ein Archiv signiert
|
||||
über 25 Binärdateien und würde sonst jedes Mal erneut fragen.
|
||||
8. **Übertragung über die Luft** (statt Kabel): `bash companion-app/scripts/ios-luftweg.sh
|
||||
https://<host>` erzeugt `manifest.plist`, Installationsseite und Symbole in
|
||||
`ios/build/luftweg/`. Der Ordner muss über **HTTPS mit öffentlich vertrauenswürdigem
|
||||
Zertifikat** ausgeliefert werden — iOS lehnt einfaches HTTP und selbstsignierte Zertifikate
|
||||
ab, Home Assistant unter `/local/` genügt also **nicht**. Passend: `tailscale serve --bg
|
||||
<ordner>` (echtes Let's-Encrypt-Zertifikat auf `*.ts.net`, kein offener Port, iPhone ohnehin
|
||||
im Tailnet); der Cloudflare-Tunnel aus Phase 12 täte es später ebenso. Link auf dem iPhone in
|
||||
**Safari** öffnen — andere Browser reichen `itms-services://` nicht ans System weiter.
|
||||
⏳ Offen: dieser Mac ist bei Tailscale abgemeldet, es läuft also noch kein Host.
|
||||
9. ✅ 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.
|
||||
@@ -481,48 +511,60 @@ nicht ein Riesencommit.
|
||||
|
||||
## 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.
|
||||
> **2026-08-28 aktualisiert/teilweise überholt.** Die Schritte unten stammen aus der frühen
|
||||
> Planungsphase (2026-08-11) und enthalten inzwischen überholte Annahmen: zwei getrennte Hostnamen
|
||||
> (App-Domain + API-Subdomain — entfällt, die App wird nie als Web-Build ausgeliefert, siehe
|
||||
> `COMPANION_APP_ARCHITECTURE.md` §5 Punkt 4) und pyscript-Entitäts-/Dienstnamen (seit der
|
||||
> Integrations-Umstellung 2026-08-23 `sensor.audi_dashboard_*`/`audi_dashboard.<name>`, siehe
|
||||
> `AGENTS.md` Abschnitt H). Reverse-Proxy-Wahl (Nginx Proxy Manager) und die vollständige, aktuelle
|
||||
> Freigabeliste stehen bereits fest. **Maßgeblich für die tatsächliche Einrichtung sind
|
||||
> [`homeassistant/INTERNET_ZUGRIFF_EINRICHTEN.md`](homeassistant/INTERNET_ZUGRIFF_EINRICHTEN.md)
|
||||
> (Schritt-für-Schritt-Anleitung) und [`homeassistant/REVERSE_PROXY.md`](homeassistant/REVERSE_PROXY.md)
|
||||
> (die Freigabeliste selbst)** - die Liste unten bleibt nur als Entscheidungsprotokoll stehen, nicht
|
||||
> als aktuelle Anleitung.
|
||||
|
||||
**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
|
||||
1. **[Besitzer] Nameserver umstellen:** `datametric360.de` 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
|
||||
Domain trägt sonst nichts, Umstellung ist folgenlos). ✅ wenn `dig NS datametric360.de` 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.
|
||||
2. ~~Hostnamen festlegen (App-Domain + getrennter API-Hostname)~~ — **überholt, siehe Hinweis oben:**
|
||||
ein einziger Hostname (`https://datametric360.de`) genügt, da die App nativ/sideload-only bleibt
|
||||
und nie als eigener Web-Build ausgeliefert wird.
|
||||
3. **Cloudflared-Add-on** in HA installieren, Tunnel erstellen, **ein** öffentlicher Hostname
|
||||
(`datametric360.de` → Reverse-Proxy-Port). Kein Router-Port wird geöffnet. Genaue Klicks:
|
||||
`INTERNET_ZUGRIFF_EINRICHTEN.md` Schritt 3/5.
|
||||
4. **Reverse Proxy:** Nginx Proxy Manager-Add-on (entschieden, nicht Traefik). Die Allowlist ist
|
||||
umfangreicher als unten ursprünglich skizziert (aktueller Entitäts-/Dienststand plus das
|
||||
OTA-Bündel unter `/audi_dashboard_static/app/*`) - wortwörtlich in `REVERSE_PROXY.md` gepflegt,
|
||||
nicht hier dupliziert. ⚠️ Ehrliche Einschränkung bleibt bestehen: `/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).
|
||||
5. **Prüfen von außen** (Mobilfunk, VPN aus): die Prüfbefehle stehen in `REVERSE_PROXY.md`
|
||||
("Nach der Einrichtung prüfen"). HA-Port 8123 ist von außen nicht erreichbar.
|
||||
6. ✅ Fertig wenn: Schritt 5 vollständig besteht und die Server-Adresse in der App eingetragen ist
|
||||
(`INTERNET_ZUGRIFF_EINRICHTEN.md` Schritt 7). In `AGENTS.md` Block B abhaken.
|
||||
|
||||
Schritte 2–4 können gegen die bestehende HA-API **vor** der FMM003-Hardware erledigt werden.
|
||||
Schritte 3–4 können gegen die bestehende HA-API erledigt werden (die FMM003-Hardware läuft seit
|
||||
2026-08-13 bereits über flespi, siehe Phase 13 unten — diese Phase hängt nicht mehr daran).
|
||||
|
||||
---
|
||||
|
||||
## Phase 13 — FMM003-Inbetriebnahme 🟡 VORBEREITET (wartet auf Hardware)
|
||||
## Phase 13 — FMM003-Inbetriebnahme ✅ ÜBERHOLT — Hardware lief bereits über flespi
|
||||
|
||||
> Gerüst der Zuordnungstabelle und alle Schritte: `homeassistant/FMM003_MAPPING.md`.
|
||||
> **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:** 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.**
|
||||
**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,
|
||||
@@ -585,6 +627,6 @@ kommen asynchron über Entitäten (Muster: Beleg-Upload).
|
||||
|
||||
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`
|
||||
umgesetzt) nativ auf dem iPhone installiert ist; der externe Zugriff über `datametric360.de`
|
||||
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).
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
# Versionierung — eine Zahl, drei Verbraucher
|
||||
|
||||
Kurzfassung für den Alltag: **Wenn du etwas änderst, erhöhe `version` in
|
||||
`custom_components/audi_dashboard/manifest.json`.** Alles Weitere ergibt sich
|
||||
daraus von selbst.
|
||||
|
||||
---
|
||||
|
||||
## Warum es diese Zahl braucht
|
||||
|
||||
Das Panel und die iOS-App werden völlig unterschiedlich ausgeliefert:
|
||||
|
||||
- **Panel:** gehört zur Integration und wird mit ihr zusammen installiert. Es
|
||||
kann gar nicht hinterherhinken — ein Update der Integration ist automatisch
|
||||
ein Update des Panels.
|
||||
- **iOS-App:** eine Capacitor-Hülle mit **fest gebündelten** Dateien. Sie
|
||||
bleibt auf dem Stand, der beim Signieren in Xcode eingebaut wurde —
|
||||
unbegrenzt.
|
||||
|
||||
Die Paritätsregel in `AGENTS.md` sichert, dass beide Codebasen in derselben
|
||||
Sitzung geändert werden. Über das, was *läuft*, sagte lange nichts etwas: die
|
||||
iOS-App konnte wochenlang zurückliegen, ohne dass es irgendwo sichtbar wurde.
|
||||
|
||||
Die Versionszahl schließt genau diese Lücke — nicht, indem sie die Abweichung
|
||||
verhindert (das kann keine Zahl), sondern indem sie sie **sichtbar** macht.
|
||||
|
||||
## Wo sie steht
|
||||
|
||||
In `custom_components/audi_dashboard/manifest.json`, Feld `version`. Format
|
||||
`2026.8.23.2` (Datum + laufende Nummer).
|
||||
|
||||
Genau dort und nirgends sonst. Home Assistant verlangt das Feld für jede
|
||||
benutzerdefinierte Integration — eine zweite Quelle für dieselbe Angabe wäre
|
||||
eine zu viel. Sie hätten irgendwann auseinandergelegen, und dann wäre der
|
||||
Vergleich still falsch geworden statt laut.
|
||||
|
||||
> HACS würde dieses Feld ebenfalls lesen und danach entscheiden, ob ein
|
||||
> Update bereitliegt — spielt hier aber keine Rolle: HACS kann laut eigener
|
||||
> Dokumentation grundsätzlich nicht mit privaten GitHub-Repositories
|
||||
> arbeiten, und das Repository ist privat (lizenzierte Audi-Assets). Das
|
||||
> Manifest-Feld bleibt trotzdem die einzig richtige Stelle - Home Assistant
|
||||
> selbst braucht es unabhängig von HACS.
|
||||
|
||||
> Bis 2026-08-23 gab es dafür eine eigene Datei `VERSION` in der Repo-Wurzel,
|
||||
> daneben eine Unix-Sekundenzahl in `www/audi-dashboard-version.json` als
|
||||
> Cache-Brecher. Beide sind mit dem Umbau zur Integration entfallen: die
|
||||
> Version steht jetzt im Manifest, und die Oberflächen-Dateien tragen sie als
|
||||
> `?v=…` in ihrer URL — die Integration hängt sie beim Anmelden des Panels an.
|
||||
> Ein Cache-Brecher, der sich nur bei echten Änderungen ändert, ist der
|
||||
> bessere: er lädt nichts unnötig neu.
|
||||
|
||||
## Wie sie durchs System läuft
|
||||
|
||||
```
|
||||
manifest.json { "version": "2026.8.23.2" }
|
||||
│
|
||||
├─► Home Assistant führt sie als Version der Integration
|
||||
│
|
||||
├─► die Integration veröffentlicht sie als
|
||||
│ sensor.audi_dashboard_app_version
|
||||
│ └─► die iOS-App vergleicht sie mit ihrer eigenen, einkompilierten
|
||||
│ Zahl und zeigt bei Abweichung einen Hinweis in der
|
||||
│ Hinweisleiste
|
||||
│
|
||||
├─► sie hängt als ?v=… an der Panel-URL
|
||||
│ └─► Browser laden Panel, CSS und Badges nur dann neu, wenn sich
|
||||
│ wirklich etwas geändert hat
|
||||
│
|
||||
└─► companion-app: vite liest das Manifest beim Bauen und setzt sie als
|
||||
__APP_VERSION__ ein (bricht ab, wenn das Feld fehlt)
|
||||
```
|
||||
|
||||
Der Vergleich läuft in eine Richtung: **die Integration sagt, welcher Stand
|
||||
installiert ist; die App sagt, welchen sie hat.** Stimmen sie nicht überein,
|
||||
ist die App zu alt (oder, seltener, die Integration).
|
||||
|
||||
**Verglichen wird der Reihe nach, nicht nur auf Gleichheit — geändert am
|
||||
04.09.2026.** Bis dahin galt hier ausdrücklich das Gegenteil (die Version sei
|
||||
eine Kennung und keine Zahl). Der Anlass war ein echter Fall: nach einem
|
||||
Xcode-Lauf lief die App auf `2026.9.4.17`, die Instanz stand noch auf
|
||||
`2026.9.4.5` — und die App bot an, sich auf `.5` zu erneuern. Ein Rückschritt um
|
||||
zwölf Fassungen, den eine Gleichheitsprüfung gar nicht sehen kann. Vorgabe des
|
||||
Eigentümers: **erkannt wird eine neuere Fassung und keine andere.**
|
||||
|
||||
Der damalige Einwand ist nicht verworfen, sondern eingebaut: sortiert wird nur,
|
||||
was dem Format `JJJJ.M.T.N` entspricht, und zwar Stelle für Stelle als Zahl —
|
||||
zeichenweise stünde `.17` vor `.5`. Passt eine Seite nicht ins Schema, gilt sie
|
||||
als **nicht sortierbar**; dann meldet die App eine Abweichung ohne Richtung und
|
||||
bietet nichts an, statt eine Reihenfolge zu erfinden. Fehlt eine der beiden
|
||||
Seiten ganz, wird gar nicht verglichen und nichts gemeldet — ein älteres Backend
|
||||
oder ein Start ohne Netz darf keinen Fehlalarm auslösen.
|
||||
|
||||
Daraus folgen vier Verhaltensweisen, entschieden in `src/daten/appVersion.ts`:
|
||||
|
||||
| Lage | Streifen | Startblatt | Update-Knopf |
|
||||
|---|---|---|---|
|
||||
| App älter als der Server | bitte aktualisieren | ja | ja |
|
||||
| App neuer (frisch signiert) | still | nein | **nein** |
|
||||
| verschieden, nicht sortierbar | neutraler Hinweis | nein | nur bei Serverübereinstimmung |
|
||||
| gleich / nicht vergleichbar | still | nein | nein |
|
||||
|
||||
## Was du tun musst
|
||||
|
||||
**Bei einer Änderung an der Oberfläche oder am Backend:**
|
||||
|
||||
1. `version` in `manifest.json` erhöhen — bei mehreren Änderungen am selben Tag
|
||||
die laufende Nummer: `2026.8.23.2` → `2026.8.23.3`, am nächsten Tag
|
||||
`2026.8.24.1`.
|
||||
2. **OTA-Bündel neu bauen: `npm run ota`** (in `companion-app/`). Baut die
|
||||
Oberfläche und packt sie mit der neuen Versionsnummer — siehe
|
||||
[OTA-Updates](#ota-updates-seit-2026-08-24) unten. Ohne diesen Schritt
|
||||
trägt `frontend/app/bundle.json` noch die alte Nummer: die Hinweisleiste
|
||||
sagt dann „du bist veraltet" (liest die Version live), die Update-Seite
|
||||
sagt „du bist aktuell" (vergleicht gegen das eingefrorene, jetzt falsche
|
||||
Bündel) — ein Widerspruch, aus dem es ohne diesen Schritt keinen Ausweg
|
||||
gibt. `install.ps1` prüft das seit 2026-08-24 selbst und warnt, falls
|
||||
vergessen — aber besser, es passiert gar nicht erst.
|
||||
3. Integration ausliefern: nach `git push` entweder in der App selbst unter
|
||||
Einstellungen → Integration-Update auf „Auf Update prüfen" → „Update
|
||||
installieren" (seit 2026-08-24, ersetzt install.ps1 als laufenden
|
||||
Update-Weg — Windows Smart App Control blockiert dessen Ausführung
|
||||
zuverlässig), danach Home Assistant neu starten; oder weiterhin
|
||||
`homeassistant\installationspaket\Installieren.cmd` von Hand
|
||||
(HACS scheidet aus — siehe Kasten oben, das Repository ist privat).
|
||||
4. iOS-App für Xcode neu bauen (`npm run build`), damit eine über App Store
|
||||
Connect / Sideload signierte Fassung dieselbe Zahl einkompiliert bekommt.
|
||||
Für alle, die die App schon installiert haben, erledigt Schritt 2 das
|
||||
automatisch über OTA — dieser Schritt ist nur für die nächste
|
||||
Neuinstallation oder Signatur-Erneuerung nötig.
|
||||
|
||||
Vergisst du Schritt 4, ist das kein stiller Fehler mehr: die App meldet selbst,
|
||||
dass sie älter ist als der Server. Vergisst du Schritt 2, bleibt es leider
|
||||
still — deshalb die Prüfung in `install.ps1`.
|
||||
|
||||
**Bei einer reinen Neuinstallation** ohne Codeänderung: nichts tun.
|
||||
|
||||
## OTA-Updates (seit 2026-08-24)
|
||||
|
||||
`@capgo/capacitor-updater` ist eingebaut. Für reine Oberflächen-Änderungen
|
||||
(JavaScript, CSS, Bilder) entfällt Schritt 3 oben — die App lädt sich den
|
||||
neuen Stand selbst, auf einen Tastendruck des Nutzers in Einstellungen →
|
||||
App-Update. Xcode wird nur noch für echte native Änderungen gebraucht
|
||||
(neue Plugins, Berechtigungen, `capacitor.config.ts`).
|
||||
|
||||
**Woher das Bündel kommt.** `companion-app/scripts/ota-paket.ps1` packt
|
||||
`dist/` (ohne Sourcemaps) in
|
||||
`custom_components/audi_dashboard/frontend/app/bundle.zip`, daneben
|
||||
`bundle.json` mit Version, SHA-256 und Größe. Weil das im Integrationsordner
|
||||
liegt, reist es bei jeder Auslieferung automatisch mit — kein zweiter Weg, den
|
||||
man vergessen könnte. Ausgeliefert wird es unter
|
||||
`/audi_dashboard_static/app/bundle.zip`, gemeldet über dasselbe Feld, das
|
||||
schon den Versionsvergleich trägt: `sensor.audi_dashboard_app_version`,
|
||||
Attribut `daten.buendel`.
|
||||
|
||||
**Was die App damit macht** (`companion-app/src/daten/ota.ts`): erscheint ein
|
||||
Bündel, dessen Version von der eigenen abweicht, zeigt Einstellungen →
|
||||
App-Update einen Knopf. Ein Tastendruck lädt die Zip, prüft sie clientseitig
|
||||
gegen die mitgelieferte SHA-256 (das Plugin selbst, nicht diese App) und
|
||||
tauscht die Oberfläche aus — die App startet dabei neu.
|
||||
|
||||
**Die Rückfallebene.** `notifyAppReady()` läuft in `App.tsx`, sobald React
|
||||
tatsächlich gerendert hat. Bleibt diese Meldung aus, weil das neue Bündel
|
||||
die App zerlegt hat, rollt das Plugin nach der eingestellten Frist
|
||||
(`appReadyTimeout` in `capacitor.config.ts`) von selbst auf das vorherige
|
||||
Bündel zurück — ein kaputtes Update kann das Gerät deshalb nicht dauerhaft
|
||||
unbrauchbar machen.
|
||||
|
||||
**Warum kein Selbstlauf.** `autoUpdate: false` — die App lädt nur auf
|
||||
ausdrücklichen Tastendruck, nie im Hintergrund. Ein Update, das während einer
|
||||
Fahrteintragung ungefragt die Oberfläche austauscht, wäre die falsche Sorte
|
||||
Hilfsbereitschaft.
|
||||
|
||||
**HACS spielt hier keine Rolle und wird es auch nicht.** Das Update-Bündel
|
||||
reist mit jedem Weg, der die Integration ausliefert, automatisch mit —
|
||||
`install.ps1` für die Erstinstallation ebenso wie das Selbst-Update seit
|
||||
2026-08-24 (aktualisierung.py, ersetzt install.ps1 als laufenden Update-Weg,
|
||||
siehe Abschnitt J in AGENTS.md) — der Mechanismus hängt nicht daran, wie die
|
||||
Integration selbst auf die Instanz kommt, nur daran, dass das Bündel im
|
||||
Integrationsordner liegt.
|
||||
@@ -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/
|
||||
|
||||
@@ -68,9 +68,29 @@ 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.
|
||||
|
||||
## Die signierte App
|
||||
|
||||
`auslieferung/App.ipa` ist der fertige, **Ad-hoc signierte** Stand (Team
|
||||
`RMACS9VLS4`, Profil gültig bis 29.08.2027, nur für das eingetragene iPhone).
|
||||
Neu bauen: `bash scripts/ios-signieren.sh` - baut Webbündel, synchronisiert die
|
||||
Hülle, signiert und legt die `.ipa` wieder hier ab.
|
||||
|
||||
Beim ersten Signieren fragt der Schlüsselbund um Erlaubnis. Dort **„Immer
|
||||
erlauben"** wählen, sonst hängt `xcodebuild` wortlos und endet nach Minuten mit
|
||||
`errSecInternalComponent`.
|
||||
|
||||
## Noch offen
|
||||
|
||||
- Capacitor-Hülle und sichere Ablage des Tokens (Keychain/Keystore)
|
||||
- Native Hülle selbst: `ios/`/`android/` (von `npx cap add` erzeugt, absichtlich
|
||||
gitignored) existieren in keinem Checkout dauerhaft und müssen auf einem Mac
|
||||
(für iOS zwingend, Xcode) neu erzeugt werden - die sichere Tokenablage
|
||||
(Keychain/Keystore) ist dagegen bereits vollständig angebunden
|
||||
(`src/api/ablageNativ.ts`, von `main.tsx` aktiviert) und wird automatisch
|
||||
aktiv, sobald die Hülle existiert
|
||||
- Übertragung über die Luft: `scripts/ios-luftweg.sh https://<host>` erzeugt
|
||||
Manifest und Installationsseite; es fehlt nur noch ein Host, der den Ordner
|
||||
über **HTTPS mit gültigem Zertifikat** ausliefert (`tailscale serve`
|
||||
angedacht, dieser Mac ist dort noch abgemeldet)
|
||||
- QR-Einrichtung als Alternative zum Einfügen des Tokens
|
||||
- Live-Ansicht der laufenden Fahrt: gebaut, aber über
|
||||
`src/funktionen.ts` abgeschaltet, bis der FMM003 echte Werte liefert
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>DataMetric360 installieren</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, sans-serif; background: #161b23; color: #eef1f6;
|
||||
margin: 0; display: grid; place-items: center; min-height: 100vh; }
|
||||
main { text-align: center; padding: 2rem; }
|
||||
h1 { font-weight: 300; font-size: 1.5rem; }
|
||||
a { display: inline-block; margin-top: 1.5rem; padding: 0.9rem 2rem; border-radius: 999px;
|
||||
background: #eef1f6; color: #161b23; text-decoration: none; font-size: 1.1rem; }
|
||||
p { color: #8a94a3; font-size: 0.9rem; line-height: 1.5; }
|
||||
</style>
|
||||
<main>
|
||||
<h1>DataMetric360</h1>
|
||||
<p>Version 2026.9.4.17</p>
|
||||
<a href="itms-services://?action=download-manifest&url=https://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/companion-app/auslieferung/manifest.plist">Installieren</a>
|
||||
<p>In Safari oeffnen. Nach dem Antippen fragt iOS einmal nach,<br>danach erscheint die App auf dem Startbildschirm.</p>
|
||||
</main>
|
||||
</html>
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>items</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>assets</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>kind</key><string>software-package</string>
|
||||
<key>url</key><string>https://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/companion-app/auslieferung/App.ipa?v=2026.9.4.17</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>kind</key><string>display-image</string>
|
||||
<key>url</key><string>https://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/companion-app/auslieferung/symbol-192.png?v=2026.9.4.17</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>kind</key><string>full-size-image</string>
|
||||
<key>url</key><string>https://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/companion-app/auslieferung/symbol-512.png?v=2026.9.4.17</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>metadata</key>
|
||||
<dict>
|
||||
<key>bundle-identifier</key><string>app.datametric360</string>
|
||||
<key>bundle-version</key><string>2026.9.4.17</string>
|
||||
<key>kind</key><string>software</string>
|
||||
<key>title</key><string>DataMetric360</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -1,33 +1,15 @@
|
||||
import type { CapacitorConfig } from "@capacitor/cli"
|
||||
|
||||
/**
|
||||
* Capacitor-Konfiguration für die native Hülle.
|
||||
*
|
||||
* **Noch nicht in Betrieb.** Die Pakete sind bewusst nicht installiert; diese
|
||||
* Datei liegt fertig da, damit die Inbetriebnahme nur noch aus den Befehlen in
|
||||
* `../UMSETZUNGSPLAN.md` Phase 10 Stufe 2 besteht:
|
||||
*
|
||||
* npm i -D @capacitor/cli
|
||||
* npm i @capacitor/core @capacitor/ios @capacitor/android
|
||||
* npx cap add ios && npx cap add android
|
||||
* npm run build && npx cap sync
|
||||
* npx cap open ios # in Xcode signieren und aufs Gerät bauen
|
||||
*
|
||||
* Nur Sideload, nie App Store: die Audi-Schrift und die Typenschilder sind
|
||||
* ausschließlich für diese private Installation freigegeben.
|
||||
* ausschließlich für diese eine private Installation freigegeben.
|
||||
*
|
||||
* Der Typ ist absichtlich nicht aus @capacitor/cli importiert — das Paket
|
||||
* fehlt ja noch, und ein toter Import würde die Typprüfung brechen.
|
||||
* Ablauf: `npm run build && npx cap sync`, dann `npx cap open ios` und in
|
||||
* Xcode signieren. Siehe `../UMSETZUNGSPLAN.md` Phase 10.
|
||||
*/
|
||||
|
||||
interface CapacitorKonfiguration {
|
||||
appId: string
|
||||
appName: string
|
||||
webDir: string
|
||||
server?: { androidScheme?: string; iosScheme?: string; cleartext?: boolean }
|
||||
ios?: { contentInset?: string; backgroundColor?: string }
|
||||
android?: { backgroundColor?: string }
|
||||
}
|
||||
|
||||
const konfiguration: CapacitorKonfiguration = {
|
||||
const konfiguration: CapacitorConfig = {
|
||||
appId: "app.datametric360",
|
||||
appName: "DataMetric360",
|
||||
webDir: "dist",
|
||||
@@ -46,6 +28,45 @@ const konfiguration: CapacitorKonfiguration = {
|
||||
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 },
|
||||
|
||||
// Oberflächen-Updates ohne Xcode (siehe src/daten/ota.ts und
|
||||
// ../VERSIONIERUNG.md). Das Bündel liegt in der Integration und wird
|
||||
// über Home Assistant ausgeliefert — keine fremde Cloud beteiligt.
|
||||
CapacitorUpdater: {
|
||||
// Kein Selbstlauf: die App lädt nur, wenn der Benutzer den Knopf in den
|
||||
// Einstellungen drückt. Ein Update, das ungefragt die Oberfläche
|
||||
// austauscht, während jemand gerade eine Fahrt einträgt, wäre die
|
||||
// schlechtere Sorte Hilfsbereitschaft.
|
||||
autoUpdate: false,
|
||||
|
||||
// Kommt eine frisch signierte App über Xcode aufs Gerät, gilt wieder
|
||||
// deren eingebautes Bündel — sonst liefe ein älteres OTA-Bündel weiter
|
||||
// und überdeckte genau die native Änderung, für die Xcode nötig war.
|
||||
resetWhenUpdate: true,
|
||||
|
||||
// Meldet sich die App nach einem Wechsel nicht binnen dieser Zeit als
|
||||
// startklar (notifyAppReady in src/main.tsx), kehrt das Plugin von
|
||||
// selbst zum vorherigen Bündel zurück. Das ist die Rückfallebene: ein
|
||||
// kaputtes Bündel kann das Gerät nicht dauerhaft lahmlegen. Bewusst
|
||||
// großzügiger als die 10 Sekunden Vorgabe — die Erstladung holt Profil,
|
||||
// Fahrten und Tankvorgänge, und ein langsames Netz darf nicht wie ein
|
||||
// defektes Bündel aussehen.
|
||||
appReadyTimeout: 20000,
|
||||
|
||||
// Ein Bündel, das beim Start durchgefallen ist, wird nicht aufbewahrt.
|
||||
autoDeleteFailed: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default konfiguration
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# Tankbeleg aus dem Teilen-Blatt
|
||||
|
||||
Ein PDF aus Mail (oder einer anderen App) über **Teilen → DataMetric360** direkt
|
||||
als Tankbeleg übernehmen.
|
||||
|
||||
## Wie es zusammenhängt
|
||||
|
||||
```
|
||||
Mail ──Teilen──► DataMetric360Share (eigener Prozess)
|
||||
│
|
||||
│ PDF → Base64 → UserDefaults der App-Gruppe
|
||||
│ group.app.datametric360
|
||||
▼
|
||||
datametric360://beleg (öffnet die App)
|
||||
│
|
||||
▼
|
||||
App.tsx ──holt ab──► Tanken-Seite ──► belegLesen()
|
||||
```
|
||||
|
||||
Eine Erweiterung ist ein eigener Prozess mit eigenem Dateibereich; der einzige
|
||||
gemeinsame Boden mit der App ist die **App-Gruppe**. Der Beleg reist deshalb
|
||||
Base64-kodiert durch die geteilten `UserDefaults` — die liest
|
||||
`@capacitor/preferences` ohnehin schon, sobald man ihm die Gruppe nennt. Eine
|
||||
Datei im Gruppen-Container wäre der sauberere Ort, bräuchte auf der App-Seite
|
||||
aber eigenen nativen Code, also ein zusätzliches Plugin für einen Weg, den es
|
||||
sonst nicht gäbe.
|
||||
|
||||
Der Preis ist eine Größengrenze von 4 MB (`grenzeBytes` im
|
||||
`ShareViewController`). Ein Tankbeleg liegt bei einigen Dutzend Kilobyte; ein
|
||||
gescanntes Mehrseiten-PDF kann darüber liegen — dann sagt die Erweiterung das,
|
||||
und der Weg über **Beleg hochladen → Datei auswählen** in der App bleibt.
|
||||
|
||||
## Wo was liegt
|
||||
|
||||
| Datei | Rolle |
|
||||
|---|---|
|
||||
| `native/teilen/ShareViewController.swift` | nimmt das PDF entgegen, legt es ab, öffnet die App |
|
||||
| `native/teilen/Info.plist` | wann DataMetric360 im Teilen-Blatt erscheint (genau ein PDF) |
|
||||
| `native/teilen/DataMetric360Share.entitlements` | App-Gruppe für die Erweiterung |
|
||||
| `native/App.entitlements` | dieselbe App-Gruppe für die App |
|
||||
| `scripts/ios-teilen-einrichten.mjs` | hängt das alles ins erzeugte Xcode-Projekt |
|
||||
| `src/daten/geteilterBeleg.ts` | holt den Beleg ab, räumt ihn weg |
|
||||
| `src/App.tsx` | prüft beim Start und bei jeder Rückkehr, springt auf Tanken |
|
||||
| `src/screens/Tanken.tsx` | nimmt ihn auf und lädt ihn hoch |
|
||||
|
||||
**`companion-app/ios/` ist gitignored** und wird von `npx cap add ios` neu
|
||||
gebaut. Alles Native muss deshalb unter `native/` liegen und bei jedem Bau
|
||||
eingespielt werden — dieselbe Regel, die schon die Team-Kennung und den
|
||||
Standort-Schlüssel in `ios-signieren.sh` getrieben hat.
|
||||
|
||||
## Einmalig im Apple-Entwicklerportal
|
||||
|
||||
Ohne diese drei Schritte schlägt das Signieren fehl (die App-Gruppe muss
|
||||
existieren, bevor ein Profil sie enthalten kann):
|
||||
|
||||
1. **Identifiers → App Groups**: `group.app.datametric360` anlegen.
|
||||
2. **Identifiers → App IDs**: `app.datametric360.teilen` anlegen, Capability
|
||||
*App Groups* aktivieren, die Gruppe zuordnen.
|
||||
3. Bei der bestehenden App-ID `app.datametric360` ebenfalls *App Groups*
|
||||
aktivieren und dieselbe Gruppe zuordnen.
|
||||
|
||||
Danach in Xcode einmal die Profile neu ziehen lassen — oder schlicht
|
||||
`ios-signieren.sh` laufen lassen, das macht `-allowProvisioningUpdates`
|
||||
selbst. **Merkposten aus Abschnitt AI:** ein zwischengespeichertes altes
|
||||
Profil wird stillschweigend weiterverwendet. Ändert sich etwas an den
|
||||
Berechtigungen, das passende `.mobileprovision` unter
|
||||
`~/Library/Developer/Xcode/UserData/Provisioning Profiles` löschen und neu
|
||||
exportieren.
|
||||
|
||||
## Bauen
|
||||
|
||||
```bash
|
||||
bash scripts/ios-signieren.sh
|
||||
```
|
||||
|
||||
Der Schritt „Share-Erweiterung einrichten" läuft dort automatisch, vor dem
|
||||
Archivieren.
|
||||
|
||||
## Wenn das Skript abbricht
|
||||
|
||||
`ios-teilen-einrichten.mjs` bearbeitet die `project.pbxproj` über das
|
||||
npm-Paket `xcode`. **Erstmals auf einem Mac gelaufen am 2026-09-02**; dabei kam
|
||||
genau ein Fehler heraus, seither behoben: die Erweiterung wurde **zweimal**
|
||||
eingebettet, weil `addTarget()` für den Typ `app_extension` von sich aus schon
|
||||
eine Copy-Phase nach `PlugIns` anlegt und das Skript eine zweite hinzufügte.
|
||||
Xcode quittierte das mit `error: Unexpected duplicate tasks`. Das Anlegen des
|
||||
Ziels selbst, die Entitlements, das URL-Schema und die Idempotenz funktionierten
|
||||
auf Anhieb.
|
||||
|
||||
Bricht es ab, ist nichts halb eingerichtet (es bricht vor dem Schreiben ab), und
|
||||
dasselbe lässt sich in Xcode von Hand erledigen:
|
||||
|
||||
1. **File → New → Target… → Share Extension**, Name `DataMetric360Share`,
|
||||
Sprache Swift, „Activate scheme" verneinen.
|
||||
2. Die von Xcode erzeugten `ShareViewController.swift`, `Info.plist` und das
|
||||
`MainInterface.storyboard` **löschen** und stattdessen die Dateien aus
|
||||
`native/teilen/` in das Ziel legen (Storyboard wird nicht gebraucht, die
|
||||
Erweiterung hat keine eigene Oberfläche).
|
||||
3. Bundle-ID des Ziels auf `app.datametric360.teilen` setzen.
|
||||
4. Beide Ziele → **Signing & Capabilities → + Capability → App Groups**,
|
||||
`group.app.datametric360` anhaken.
|
||||
5. Ziel `DataMetric360Share` → Build Settings → *Code Signing Entitlements* auf
|
||||
`DataMetric360Share/DataMetric360Share.entitlements`; Ziel `App` auf
|
||||
`App/App.entitlements`.
|
||||
6. Ziel `App` → Build Phases → **Embed App Extensions** muss
|
||||
`DataMetric360Share.appex` enthalten.
|
||||
7. `ios/App/App/Info.plist`: URL-Schema `datametric360` unter
|
||||
`CFBundleURLTypes` eintragen.
|
||||
|
||||
Schritt 7 ist der leiseste: fehlt er, wird der Beleg abgelegt, die App aber
|
||||
nicht geöffnet — er taucht dann erst auf, wenn man die App das nächste Mal von
|
||||
Hand startet.
|
||||
|
||||
## Prüfen, ob es wirkt
|
||||
|
||||
1. In Mail ein PDF antippen → **Teilen** → DataMetric360 muss in der Liste
|
||||
stehen (nur bei genau einem PDF, sonst absichtlich nicht).
|
||||
2. Antippen → die App kommt nach vorn, springt auf **Tanken**, das Formular ist
|
||||
offen und liest den Beleg („Beleg wird gelesen …").
|
||||
3. Danach ist der Eintrag weg: dieselbe Datei ein zweites Mal zu teilen muss
|
||||
wieder funktionieren, ein bloßes Wechseln in die App darf den Beleg **nicht**
|
||||
erneut verarbeiten.
|
||||
|
||||
## Was hier nicht steht
|
||||
|
||||
Android. Der Ablauf ist dort ein anderer (`intent-filter` statt App-Gruppe),
|
||||
und die Android-Hülle dieses Projekts ist ohnehin nicht in Gebrauch.
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Entitlements der Haupt-App. Bislang hatte sie keine eigene Datei - erst die
|
||||
App-Gruppe der Share-Erweiterung macht eine noetig. Derselbe Bezeichner wie
|
||||
in native/teilen/DataMetric360Share.entitlements.
|
||||
-->
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.app.datametric360</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 70 KiB |
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon-hell.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "AppIcon-dunkel.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"filename" : "AppIcon-getoent.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//
|
||||
// BelegZwischenablage.swift
|
||||
// DataMetric360
|
||||
//
|
||||
// Holt ein PDF aus der System-Zwischenablage — der Weg, den die Web-API
|
||||
// nicht anbietet.
|
||||
//
|
||||
// WARUM ES DAS BRAUCHT
|
||||
// --------------------
|
||||
// `navigator.clipboard.read()` gibt aus Datenschutzgründen nur Text, HTML
|
||||
// und Bilder heraus; ein kopiertes PDF taucht dort NIE auf. Der Knopf „Aus
|
||||
// Zwischenablage einfügen" endete deshalb auf dem Telefon zwangsläufig mit
|
||||
// „In der Zwischenablage liegt kein PDF" (gemeldet vom Eigentümer,
|
||||
// 02.09.2026). Das ist keine Lücke in unserem Code, sondern die Grenze der
|
||||
// Schnittstelle.
|
||||
//
|
||||
// `UIPasteboard` kennt diese Grenze nicht: es gibt beliebige Typen heraus,
|
||||
// auch `com.adobe.pdf`. Der Preis ist derselbe wie im Web — iOS zeigt beim
|
||||
// Zugriff seine eigene Einfügen-Rückfrage, und das ist richtig so.
|
||||
//
|
||||
// DREI FORMEN, IN DENEN EIN PDF IN DER ZWISCHENABLAGE LIEGT
|
||||
// ---------------------------------------------------------
|
||||
// 1. Als Daten unter `com.adobe.pdf` — so legen es die meisten Apps ab.
|
||||
// 2. Als Datei-URL — Mail kopiert einen Anhang oft nur als Verweis auf eine
|
||||
// temporäre Kopie. Ohne den zweiten Weg bliebe genau der Fall übrig, für
|
||||
// den der Knopf gedacht ist.
|
||||
// 3. Als Zusage (`NSItemProvider`) — die Datei existiert dann noch gar nicht,
|
||||
// die kopierende App liefert sie erst auf Anforderung. `data(forPasteboardType:)`
|
||||
// gibt dabei nil zurück und `urls` bleibt leer; nur der Anbieter kennt sie.
|
||||
// Das ist Apples eigener, empfohlener Weg und der einzige, der auch eine
|
||||
// Datei aus einer Sandbox einer anderen App zuverlässig herausgibt — Weg 2
|
||||
// scheitert dort am Lesezugriff. Er ist asynchron, deshalb steht er hier
|
||||
// zuletzt: die beiden schnellen Wege sollen nicht auf ihn warten.
|
||||
//
|
||||
// DIESE DATEI IST VERSIONIERT, ios/ NICHT.
|
||||
// `npx cap add ios` erzeugt den nativen Ordner neu und würde alles darin
|
||||
// verlieren. scripts/ios-teilen-einrichten.mjs kopiert sie bei jedem Bau
|
||||
// wieder hinein und hängt sie an die Sources-Phase des App-Ziels.
|
||||
//
|
||||
|
||||
import Capacitor
|
||||
import Foundation
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@objc(BelegZwischenablagePlugin)
|
||||
public class BelegZwischenablagePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
public let identifier = "BelegZwischenablagePlugin"
|
||||
public let jsName = "BelegZwischenablage"
|
||||
public let pluginMethods: [CAPPluginMethod] = [
|
||||
CAPPluginMethod(name: "pdfHolen", returnType: CAPPluginReturnPromise)
|
||||
]
|
||||
|
||||
/// Liefert `{ base64, name }`, wenn ein PDF in der Zwischenablage liegt —
|
||||
/// sonst ein leeres Ergebnis. Bewusst kein Fehler: „nichts kopiert" ist
|
||||
/// kein Fehlschlag, sondern eine Antwort, und die Oberfläche sagt dazu
|
||||
/// ohnehin das Passende (zwischenablage.ts).
|
||||
@objc func pdfHolen(_ call: CAPPluginCall) {
|
||||
let ablage = UIPasteboard.general
|
||||
|
||||
if let daten = ablage.data(forPasteboardType: UTType.pdf.identifier) {
|
||||
return call.resolve(["base64": daten.base64EncodedString(), "name": "beleg.pdf"])
|
||||
}
|
||||
|
||||
// Mail legt den Anhang oft nur als Verweis ab.
|
||||
for url in ablage.urls ?? [] where url.pathExtension.lowercased() == "pdf" {
|
||||
// Sicherheitsbereich anfordern: die URL zeigt auf eine Datei
|
||||
// außerhalb des eigenen Sandkastens, sonst schlägt das Lesen fehl.
|
||||
let erlaubt = url.startAccessingSecurityScopedResource()
|
||||
defer { if erlaubt { url.stopAccessingSecurityScopedResource() } }
|
||||
if let daten = try? Data(contentsOf: url) {
|
||||
return call.resolve([
|
||||
"base64": daten.base64EncodedString(),
|
||||
"name": url.lastPathComponent,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// Dritter Weg: die Datei liegt nur als Zusage vor.
|
||||
let anbieter = ablage.itemProviders.first {
|
||||
$0.hasItemConformingToTypeIdentifier(UTType.pdf.identifier)
|
||||
}
|
||||
guard let anbieter else {
|
||||
return call.resolve([:])
|
||||
}
|
||||
anbieter.loadDataRepresentation(forTypeIdentifier: UTType.pdf.identifier) { daten, _ in
|
||||
guard let daten, !daten.isEmpty else {
|
||||
// Kein Fehler: die Oberfläche sagt zu einem leeren Ergebnis
|
||||
// ohnehin das Passende (zwischenablage.ts).
|
||||
return call.resolve([:])
|
||||
}
|
||||
let name = anbieter.suggestedName.map { $0.hasSuffix(".pdf") ? $0 : $0 + ".pdf" }
|
||||
call.resolve([
|
||||
"base64": daten.base64EncodedString(),
|
||||
"name": name ?? "beleg.pdf",
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//
|
||||
// KalenderTermin.swift
|
||||
// DataMetric360
|
||||
//
|
||||
// Legt einen Termin über EventKit an — der Weg, den das Teilen-Blatt nicht
|
||||
// anbietet.
|
||||
//
|
||||
// WARUM ES DAS BRAUCHT
|
||||
// --------------------
|
||||
// Bis zum 03.09.2026 ging der Werkstatt- und der Radwechseltermin als
|
||||
// .ics-Datei ins iOS-Teilen-Blatt. Dort erscheint nie ein Kalender, und das
|
||||
// liegt nicht an der Datei: Apples Kalender-App meldet sich beim System
|
||||
// überhaupt nicht als Teilen-Ziel an, für kein Format. Das Blatt kann sie
|
||||
// deshalb gar nicht zeigen, egal wie die Datei aussieht.
|
||||
//
|
||||
// Dass ein per Mail geschickter Anhang funktioniert, führt in die Irre: dort
|
||||
// öffnet nicht das Teilen-Blatt, sondern die Dokumentvorschau des Systems
|
||||
// (Quick Look). Die erkennt text/calendar und blendet „Alle hinzufügen" ein.
|
||||
// Ein anderer Mechanismus, den eine App nicht aus dem Teilen-Blatt heraus
|
||||
// erreicht. Gemeldet vom Eigentümer am 02.09.2026.
|
||||
//
|
||||
// EventKit umgeht beides: der Termin geht direkt an den Kalender.
|
||||
//
|
||||
// WARUM DER SYSTEM-EDITOR UND NICHT `EKEventStore.save`
|
||||
// -----------------------------------------------------
|
||||
// `EKEventEditViewController` zeigt den fertig ausgefüllten Termin und lässt
|
||||
// ihn den Menschen bestätigen. Das passt zur Vorgabe des Eigentümers, dass
|
||||
// der Werkstatttermin keine eigene Verwaltung bekommt (siehe Abschnitt BQ in
|
||||
// AGENTS.md): die App liefert „was, wann, welche Werkstatt" und ist danach
|
||||
// raus — gespeichert wird im Kalender, nicht bei uns.
|
||||
//
|
||||
// Der zweite Grund ist die Berechtigung. Seit iOS 17 braucht der System-
|
||||
// Editor keine (WWDC23, „Discover Calendar and EventKit"): er läuft außerhalb
|
||||
// unseres Zugriffs, wir übergeben nur den Entwurf. Ein direktes `save()`
|
||||
// bräuchte dagegen eine Freigabe samt Rückfrage. Deshalb wird hier auch
|
||||
// `event.calendar` NICHT gesetzt — das würde `defaultCalendarForNewEvents`
|
||||
// lesen und damit doch eine Freigabe verlangen; der Editor sucht den
|
||||
// Standardkalender selbst.
|
||||
//
|
||||
// DIESE DATEI IST VERSIONIERT, ios/ NICHT.
|
||||
// `npx cap add ios` erzeugt den nativen Ordner neu und würde alles darin
|
||||
// verlieren. scripts/ios-teilen-einrichten.mjs kopiert sie bei jedem Bau
|
||||
// wieder hinein und hängt sie an die Sources-Phase des App-Ziels.
|
||||
//
|
||||
|
||||
import Capacitor
|
||||
import EventKit
|
||||
import EventKitUI
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
@objc(KalenderTerminPlugin)
|
||||
public class KalenderTerminPlugin: CAPPlugin, CAPBridgedPlugin, EKEventEditViewDelegate {
|
||||
public let identifier = "KalenderTerminPlugin"
|
||||
public let jsName = "KalenderTermin"
|
||||
public let pluginMethods: [CAPPluginMethod] = [
|
||||
CAPPluginMethod(name: "terminAnlegen", returnType: CAPPluginReturnPromise)
|
||||
]
|
||||
|
||||
/// Muss den Editor überleben — eine lokale Instanz wäre beim Erscheinen
|
||||
/// des Blatts schon wieder abgeräumt.
|
||||
private let speicher = EKEventStore()
|
||||
|
||||
/// Der Aufruf wartet, bis der Mensch im Editor entschieden hat.
|
||||
private var offenerAufruf: CAPPluginCall?
|
||||
|
||||
@objc func terminAnlegen(_ call: CAPPluginCall) {
|
||||
guard let titel = call.getString("titel"), !titel.isEmpty else {
|
||||
return call.reject("Ohne Titel kein Termin.")
|
||||
}
|
||||
guard let isoTag = call.getString("isoTag"), let tag = tagAus(isoTag) else {
|
||||
return call.reject("Kein gültiges Datum (erwartet JJJJ-MM-TT).")
|
||||
}
|
||||
|
||||
let termin = EKEvent(eventStore: speicher)
|
||||
termin.title = titel
|
||||
termin.isAllDay = true
|
||||
// Ganztägig heißt für EventKit: Beginn und Ende am selben Tag. Deckt
|
||||
// sich mit dem DTSTART;VALUE=DATE der bisherigen .ics.
|
||||
termin.startDate = tag
|
||||
termin.endDate = tag
|
||||
if let ort = call.getString("ort"), !ort.isEmpty { termin.location = ort }
|
||||
if let text = call.getString("beschreibung"), !text.isEmpty { termin.notes = text }
|
||||
// Zwei Tage vorher erinnern - dieselbe Vorwarnzeit wie im TRIGGER:-P2D
|
||||
// der .ics, die dieser Weg ablöst.
|
||||
termin.addAlarm(EKAlarm(relativeOffset: -2 * 24 * 60 * 60))
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let eltern = self.bridge?.viewController else {
|
||||
return call.reject("Kein Fenster zum Anzeigen des Kalender-Editors.")
|
||||
}
|
||||
|
||||
// Ein noch offener Aufruf kann nur von einem Editor stammen, der
|
||||
// ohne Rueckmeldung verschwunden ist. Ihn stehen zu lassen hiesse,
|
||||
// ein Versprechen nie einzuloesen.
|
||||
self.offenerAufruf?.resolve(["status": "abgebrochen"])
|
||||
self.offenerAufruf = call
|
||||
|
||||
let editor = EKEventEditViewController()
|
||||
editor.event = termin
|
||||
editor.eventStore = self.speicher
|
||||
editor.editViewDelegate = self
|
||||
// Wegwischen wuerde den Delegaten nicht rufen und den Aufruf oben
|
||||
// haengen lassen; so bleiben nur "Hinzufuegen" und "Abbrechen".
|
||||
editor.isModalInPresentation = true
|
||||
eltern.present(editor, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
public func eventEditViewController(
|
||||
_ controller: EKEventEditViewController,
|
||||
didCompleteWith action: EKEventEditViewAction
|
||||
) {
|
||||
controller.dismiss(animated: true)
|
||||
let aufruf = offenerAufruf
|
||||
offenerAufruf = nil
|
||||
// .deleted gibt es hier nicht (der Termin ist neu), zaehlt aber wie ein
|
||||
// Abbruch: gespeichert wurde nichts.
|
||||
aufruf?.resolve(["status": action == .saved ? "gespeichert" : "abgebrochen"])
|
||||
}
|
||||
|
||||
/// „2027-03-14" -> Mitternacht dieses Tages in der Zeitzone des Geräts.
|
||||
private func tagAus(_ isoTag: String) -> Date? {
|
||||
let teile = isoTag.split(separator: "-").compactMap { Int($0) }
|
||||
guard teile.count == 3 else { return nil }
|
||||
var komponenten = DateComponents()
|
||||
komponenten.year = teile[0]
|
||||
komponenten.month = teile[1]
|
||||
komponenten.day = teile[2]
|
||||
var kalender = Calendar(identifier: .gregorian)
|
||||
kalender.timeZone = TimeZone.current
|
||||
return kalender.date(from: komponenten)
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Die App-Gruppe ist der einzige gemeinsame Boden zwischen Erweiterung und
|
||||
App - ohne sie kann die Erweiterung nichts uebergeben. Derselbe Bezeichner
|
||||
muss in App.entitlements stehen und im Apple-Entwicklerportal als App Group
|
||||
angelegt sein.
|
||||
-->
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.app.datametric360</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Info.plist der Share-Erweiterung.
|
||||
|
||||
NSExtensionActivationRule entscheidet, wann DataMetric360 im Teilen-Blatt
|
||||
ueberhaupt auftaucht. Bewusst eng: genau EIN PDF, sonst nichts. Die
|
||||
Erweiterung kann nur Tankbelege, und ein Eintrag, der bei jedem Foto und
|
||||
jedem Link mitangeboten wird und dann ablehnt, ist schlechter als keiner.
|
||||
|
||||
NSExtensionPrincipalClass zeigt auf die Swift-Klasse ohne Storyboard -
|
||||
die Erweiterung hat keine eigene Oberflaeche, sie nimmt die Datei entgegen
|
||||
und oeffnet die App. Deshalb steht hier auch kein NSExtensionMainStoryboard.
|
||||
-->
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>de</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>DataMetric360</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationSupportsFileWithMaxCount</key>
|
||||
<integer>1</integer>
|
||||
<key>NSExtensionActivationSupportsAttachmentsWithMaxCount</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.share-services</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,172 @@
|
||||
//
|
||||
// ShareViewController.swift
|
||||
// DataMetric360Share
|
||||
//
|
||||
// Nimmt ein PDF aus dem iOS-Teilen-Blatt entgegen (typischerweise ein
|
||||
// Tankbeleg aus Mail) und reicht es an die App weiter.
|
||||
//
|
||||
// WIE DIE ÜBERGABE FUNKTIONIERT — UND WARUM SO
|
||||
// --------------------------------------------
|
||||
// Eine Share-Erweiterung ist ein eigener Prozess mit eigenem Dateibereich.
|
||||
// Sie kann der App nichts direkt geben; der einzige gemeinsame Boden ist
|
||||
// eine App-Gruppe. Der Weg hier:
|
||||
//
|
||||
// 1. Das PDF wird in den Container der App-Gruppe geschrieben.
|
||||
// 2. Die Erweiterung öffnet die App über ihr URL-Schema und gibt den
|
||||
// Pfad mit: `datametric360://beleg?pfad=<file://…>`.
|
||||
// 3. Die App liest die Datei über @capacitor/filesystem (das volle
|
||||
// file://-Pfade ausdrücklich unterstützt) und löscht sie danach.
|
||||
//
|
||||
// DER ERSTE ENTWURF GING ÜBER USERDEFAULTS UND HAT NIE FUNKTIONIERT.
|
||||
// Er legte das PDF base64-kodiert unter `dm360_geteilter_beleg` in
|
||||
// `UserDefaults(suiteName: gruppe)` ab, weil die App UserDefaults über
|
||||
// @capacitor/preferences ohnehin liest. Nur liest dieses Plugin (8.0.1,
|
||||
// ios/Sources/PreferencesPlugin/Preferences.swift) IMMER
|
||||
// `UserDefaults.standard` der App — `configure({ group })` wechselt nicht
|
||||
// den Container, sondern setzt nur ein Schlüssel-Präfix. Die App suchte
|
||||
// also `group.app.datametric360.dm360_geteilter_beleg` in ihrem eigenen
|
||||
// Bereich, während hier der plain Schlüssel im Gruppen-Container lag:
|
||||
// falscher Behälter und falscher Schlüssel. Gemeldet am 02.09.2026
|
||||
// („es wird kein Tankvorgang angelegt beim Teilen mit der App“).
|
||||
//
|
||||
// Die Datei hat zwei weitere Vorteile: keine Base64-Aufblähung um ein
|
||||
// Drittel, und keine 4-MB-Grenze — UserDefaults ist für Einstellungen
|
||||
// gedacht, nicht für Anhänge. Geblieben ist eine Obergrenze, aber eine
|
||||
// großzügige (GRENZE_BYTES).
|
||||
//
|
||||
// DIESE DATEI IST VERSIONIERT, ios/ NICHT.
|
||||
// `npx cap add ios` erzeugt den nativen Ordner neu und würde alles darin
|
||||
// verlieren. scripts/ios-teilen-einrichten.mjs kopiert diese Datei bei
|
||||
// jedem Bau wieder hinein und hängt das Ziel ins Xcode-Projekt.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Social
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
class ShareViewController: UIViewController {
|
||||
|
||||
/// Muss zur App-Gruppe in beiden Entitlements-Dateien passen.
|
||||
private let gruppe = "group.app.datametric360"
|
||||
|
||||
/// Unterordner im Gruppen-Container, in dem geteilte Belege liegen.
|
||||
private let ordner = "geteilte-belege"
|
||||
|
||||
/// 24 MB. Kein technisches Limit mehr, sondern eine Vernunftgrenze:
|
||||
/// ein Tankbeleg liegt bei einigen Dutzend Kilobyte, alles jenseits
|
||||
/// davon ist kein Beleg, sondern ein Versehen.
|
||||
private let grenzeBytes = 24 * 1024 * 1024
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .clear
|
||||
pdfUebernehmen()
|
||||
}
|
||||
|
||||
private func pdfUebernehmen() {
|
||||
guard
|
||||
let eintrag = (extensionContext?.inputItems as? [NSExtensionItem])?.first,
|
||||
let anhaenge = eintrag.attachments
|
||||
else {
|
||||
return abschliessen()
|
||||
}
|
||||
|
||||
let pdfTyp = UTType.pdf.identifier
|
||||
guard let anhang = anhaenge.first(where: { $0.hasItemConformingToTypeIdentifier(pdfTyp) })
|
||||
else {
|
||||
return melden("Kein PDF dabei.")
|
||||
}
|
||||
|
||||
anhang.loadItem(forTypeIdentifier: pdfTyp, options: nil) { [weak self] wert, _ in
|
||||
guard let self else { return }
|
||||
|
||||
// Je nach Quelle kommt eine URL, ein Data-Objekt oder nichts
|
||||
// Brauchbares an — Mail liefert eine URL auf eine temporäre
|
||||
// Kopie, andere Apps reichen die Bytes direkt.
|
||||
var daten: Data?
|
||||
var name = "beleg.pdf"
|
||||
|
||||
if let url = wert as? URL {
|
||||
daten = try? Data(contentsOf: url)
|
||||
name = url.lastPathComponent
|
||||
} else if let roh = wert as? Data {
|
||||
daten = roh
|
||||
}
|
||||
|
||||
guard let daten else {
|
||||
return self.melden("Der Beleg ließ sich nicht lesen.")
|
||||
}
|
||||
guard daten.count <= self.grenzeBytes else {
|
||||
return self.melden(
|
||||
"Der Beleg ist zu groß für diesen Weg. In der App über „Beleg hochladen“ → „Datei auswählen“.")
|
||||
}
|
||||
|
||||
self.ablegen(daten: daten, name: name)
|
||||
}
|
||||
}
|
||||
|
||||
private func ablegen(daten: Data, name: String) {
|
||||
guard
|
||||
let container = FileManager.default.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: gruppe)
|
||||
else {
|
||||
return melden("Die App-Gruppe ist nicht eingerichtet.")
|
||||
}
|
||||
|
||||
let ziel = container.appendingPathComponent(ordner, isDirectory: true)
|
||||
// Ein Zeitstempel im Namen: zwei rasch nacheinander geteilte Belege
|
||||
// sollen sich nicht gegenseitig überschreiben, und der Name bleibt
|
||||
// für den Nutzer lesbar, falls er den Container je zu sehen bekommt.
|
||||
let datei = ziel.appendingPathComponent(
|
||||
"\(Int(Date().timeIntervalSince1970))-\(name)")
|
||||
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: ziel, withIntermediateDirectories: true)
|
||||
try daten.write(to: datei, options: .atomic)
|
||||
} catch {
|
||||
return melden("Der Beleg ließ sich nicht übergeben.")
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.appOeffnen(pfad: datei)
|
||||
self?.abschliessen()
|
||||
}
|
||||
}
|
||||
|
||||
/// Öffnet die Host-App. Eine Erweiterung darf `UIApplication.shared` nicht
|
||||
/// benutzen, deshalb der Umweg über die Responder-Kette — der einzige von
|
||||
/// Apple vorgesehene Weg aus einer Erweiterung heraus.
|
||||
private func appOeffnen(pfad: URL) {
|
||||
// Der Pfad wandert als Abfrageparameter mit. Er zeigt in den
|
||||
// Gruppen-Container, den die App mit derselben Berechtigung lesen
|
||||
// darf - kein zusätzliches Plugin nötig.
|
||||
var bau = URLComponents()
|
||||
bau.scheme = "datametric360"
|
||||
bau.host = "beleg"
|
||||
bau.queryItems = [URLQueryItem(name: "pfad", value: pfad.absoluteString)]
|
||||
guard let ziel = bau.url else { return }
|
||||
var antwort: UIResponder? = self
|
||||
while let r = antwort {
|
||||
if let app = r as? UIApplication {
|
||||
app.open(ziel, options: [:], completionHandler: nil)
|
||||
return
|
||||
}
|
||||
antwort = r.next
|
||||
}
|
||||
}
|
||||
|
||||
private func melden(_ text: String) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
let hinweis = UIAlertController(title: "DataMetric360", message: text, preferredStyle: .alert)
|
||||
hinweis.addAction(UIAlertAction(title: "Verstanden", style: .default) { _ in
|
||||
self?.abschliessen()
|
||||
})
|
||||
self?.present(hinweis, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func abschliessen() {
|
||||
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
}
|
||||
}
|
||||
@@ -11,15 +11,27 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"smoke": "node --experimental-strip-types scripts/smoke.ts",
|
||||
"smoke:auth": "node --experimental-strip-types scripts/smoke-auth.ts",
|
||||
"ota": "powershell -ExecutionPolicy Bypass -File scripts/ota-paket.ps1 -Bauen",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aparajita/capacitor-secure-storage": "^8.0.0",
|
||||
"@audi-dash/ui": "*",
|
||||
"@capacitor/android": "^8.5.0",
|
||||
"@capacitor/app": "^8.1.1",
|
||||
"@capacitor/core": "^8.5.0",
|
||||
"@capacitor/filesystem": "^8.1.3",
|
||||
"@capacitor/ios": "^8.5.0",
|
||||
"@capacitor/preferences": "^8.0.1",
|
||||
"@capacitor/share": "^8.0.1",
|
||||
"@capgo/capacitor-updater": "^8.51.14",
|
||||
"jsqr": "^1.4.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",
|
||||
@@ -27,8 +39,10 @@
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"jsdom": "^29.1.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.7",
|
||||
"vitest": "^2.1.8"
|
||||
"vitest": "^2.1.8",
|
||||
"xcode": "^3.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bereitet die Installation ueber die Luft vor: legt neben der signierten .ipa
|
||||
# die manifest.plist und eine kleine Installationsseite ab.
|
||||
#
|
||||
# bash scripts/ios-luftweg.sh https://mein-mac.tailnetname.ts.net
|
||||
#
|
||||
# Warum das noetig ist: iOS installiert eine App nur dann ueber die Luft, wenn
|
||||
# ein itms-services-Link auf eine manifest.plist zeigt, die wiederum die
|
||||
# Adresse der .ipa enthaelt. Beide Adressen muessen ueber **HTTPS mit einem
|
||||
# oeffentlich vertrauenswuerdigen Zertifikat** erreichbar sein - einfaches HTTP
|
||||
# und selbstsignierte Zertifikate lehnt iOS kommentarlos ab. Home Assistant
|
||||
# unter /local/ genuegt dafuer nicht, das laeuft im Tailnet unverschluesselt.
|
||||
#
|
||||
# Passend dazu und ohne offenen Port: `tailscale serve` liefert ein echtes
|
||||
# Let's-Encrypt-Zertifikat auf <maschine>.<tailnet>.ts.net aus. Voraussetzung
|
||||
# ist, dass in der Tailscale-Verwaltung HTTPS-Zertifikate aktiviert sind.
|
||||
#
|
||||
# tailscale serve --bg ios/build/luftweg
|
||||
#
|
||||
# Danach den ausgegebenen Link auf dem iPhone in **Safari** oeffnen (andere
|
||||
# Browser reichen itms-services nicht an das System weiter).
|
||||
set -euo pipefail
|
||||
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "Aufruf: bash scripts/ios-luftweg.sh <basis-url> [zielordner]" >&2
|
||||
echo "Beispiel: bash scripts/ios-luftweg.sh https://mein-mac.tailnetname.ts.net" >&2
|
||||
echo "Gitea: bash scripts/ios-luftweg.sh \\" >&2
|
||||
echo " https://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/companion-app/auslieferung \\" >&2
|
||||
echo " auslieferung" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BASIS="${1%/}"
|
||||
if [ "${BASIS#https://}" = "$BASIS" ]; then
|
||||
echo "FEHLER: Die Basis-Adresse muss mit https:// beginnen." >&2
|
||||
echo "iOS verweigert die Installation ueber die Luft ueber unverschluesseltes HTTP." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HIER="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
APP="$(dirname "$HIER")"
|
||||
IPA="$APP/auslieferung/App.ipa"
|
||||
# Zweites Argument erlaubt es, direkt in den versionierten Ordner zu schreiben -
|
||||
# noetig, wenn nicht ein eigener Host ausliefert, sondern das Repository selbst.
|
||||
ZIEL="$APP/${2:-ios/build/luftweg}"
|
||||
|
||||
[ -f "$IPA" ] || { echo "FEHLER: $IPA fehlt - erst scripts/ios-signieren.sh laufen lassen." >&2; exit 1; }
|
||||
|
||||
# Version aus der .ipa selbst lesen statt sie hier zu wiederholen: zwei Quellen
|
||||
# fuer dieselbe Angabe waeren eine zu viel.
|
||||
AUSPACK="$(mktemp -d)"
|
||||
trap 'rm -rf "$AUSPACK"' EXIT
|
||||
unzip -q "$IPA" -d "$AUSPACK"
|
||||
PLIST="$(find "$AUSPACK/Payload" -maxdepth 2 -name Info.plist | head -1)"
|
||||
KENNUNG="$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$PLIST")"
|
||||
VERSION="$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$PLIST")"
|
||||
|
||||
mkdir -p "$ZIEL"
|
||||
[ "$IPA" -ef "$ZIEL/App.ipa" ] || cp "$IPA" "$ZIEL/App.ipa"
|
||||
cp "$APP/public/symbol-192.png" "$ZIEL/symbol-192.png"
|
||||
cp "$APP/public/symbol-512.png" "$ZIEL/symbol-512.png"
|
||||
|
||||
# Die Adressen tragen die Version als Anhaengsel (?v=...). Sie zeigen sonst bei
|
||||
# jedem Bau auf dieselbe Zeichenkette, und der Installations-Dienst von iOS haelt
|
||||
# eine einmal geholte .ipa dann fuer schon bekannt - sichtbar als Wolke mit Pfeil,
|
||||
# die nie fertig wird, oder als "kann nicht installiert werden". Dieselbe Vorsorge
|
||||
# trifft das Panel seit jeher fuer sein eigenes Buendel (siehe ota.ts).
|
||||
cat > "$ZIEL/manifest.plist" <<PLISTENDE
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>items</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>assets</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>kind</key><string>software-package</string>
|
||||
<key>url</key><string>$BASIS/App.ipa?v=$VERSION</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>kind</key><string>display-image</string>
|
||||
<key>url</key><string>$BASIS/symbol-192.png?v=$VERSION</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>kind</key><string>full-size-image</string>
|
||||
<key>url</key><string>$BASIS/symbol-512.png?v=$VERSION</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>metadata</key>
|
||||
<dict>
|
||||
<key>bundle-identifier</key><string>$KENNUNG</string>
|
||||
<key>bundle-version</key><string>$VERSION</string>
|
||||
<key>kind</key><string>software</string>
|
||||
<key>title</key><string>DataMetric360</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
PLISTENDE
|
||||
|
||||
# Das kaufmaennische Und muss in HTML maskiert werden, sonst schneidet Safari
|
||||
# den Link hinter dem url-Parameter ab.
|
||||
cat > "$ZIEL/index.html" <<HTMLENDE
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>DataMetric360 installieren</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, sans-serif; background: #161b23; color: #eef1f6;
|
||||
margin: 0; display: grid; place-items: center; min-height: 100vh; }
|
||||
main { text-align: center; padding: 2rem; }
|
||||
h1 { font-weight: 300; font-size: 1.5rem; }
|
||||
a { display: inline-block; margin-top: 1.5rem; padding: 0.9rem 2rem; border-radius: 999px;
|
||||
background: #eef1f6; color: #161b23; text-decoration: none; font-size: 1.1rem; }
|
||||
p { color: #8a94a3; font-size: 0.9rem; line-height: 1.5; }
|
||||
</style>
|
||||
<main>
|
||||
<h1>DataMetric360</h1>
|
||||
<p>Version $VERSION</p>
|
||||
<a href="itms-services://?action=download-manifest&url=$BASIS/manifest.plist">Installieren</a>
|
||||
<p>In Safari oeffnen. Nach dem Antippen fragt iOS einmal nach,<br>danach erscheint die App auf dem Startbildschirm.</p>
|
||||
</main>
|
||||
</html>
|
||||
HTMLENDE
|
||||
|
||||
echo "Ordner fertig: $ZIEL"
|
||||
echo
|
||||
echo "Wenn ein eigener Host ausliefert:"
|
||||
echo " tailscale serve --bg $ZIEL"
|
||||
echo " dann auf dem iPhone in Safari oeffnen: $BASIS/"
|
||||
echo
|
||||
# Gitea liefert rohe Dateien grundsaetzlich als text/plain aus - die
|
||||
# index.html wird dort also als Quelltext angezeigt statt gerendert. Deshalb
|
||||
# hier zusaetzlich der fertige Link zum Einfuegen in die Safari-Adresszeile;
|
||||
# der funktioniert unabhaengig davon, ob eine Seite drumherum existiert.
|
||||
echo "Ohne Seite (z. B. aus dem Repository heraus) direkt in Safari einfuegen:"
|
||||
echo " itms-services://?action=download-manifest&url=$BASIS/manifest.plist"
|
||||
@@ -0,0 +1,424 @@
|
||||
#!/usr/bin/env bash
|
||||
# Baut die native iOS-Huelle und signiert sie zu einer installierbaren .ipa.
|
||||
# Nur auf einem Mac mit Xcode lauffaehig. Siehe ../../UMSETZUNGSPLAN.md Phase 10.
|
||||
#
|
||||
# Warum es dieses Skript gibt: `ios/` ist absichtlich gitignored (aus dem
|
||||
# Webbuendel jederzeit wiederherstellbar). Damit verschwindet aber auch jede
|
||||
# Signatureinstellung, die man in Xcode von Hand setzt, beim naechsten
|
||||
# `npx cap add ios` wieder. Die Team-Kennung gehoert deshalb hierher - in eine
|
||||
# versionierte Datei - und wird dem Build von aussen mitgegeben.
|
||||
#
|
||||
# Voraussetzung, die dieses Skript nicht herstellen kann: das Zielgeraet muss
|
||||
# im Team eingetragen sein. Das Ad-hoc-Profil traegt eine feste Liste von
|
||||
# Geraetekennungen, ein unbekanntes Telefon lehnt die fertige .ipa ab. Ein
|
||||
# neues Geraet wird einmalig angemeldet - per Kabel an diesem Mac, dann
|
||||
# erledigt es -allowProvisioningUpdates selbst, oder von Hand auf
|
||||
# developer.apple.com. Bricht der Archivschritt mit "Your team has no devices"
|
||||
# ab, fehlt genau dieser Eintrag; das ist kein Fehler im Projekt.
|
||||
#
|
||||
# Stand des Kontos, am 2026-08-29 am ausgestellten Profil nachgesehen statt
|
||||
# vermutet: bezahlte Mitgliedschaft, Team RMACS9VLS4, Laufzeit ein Jahr. Die
|
||||
# frueher hier vermerkten sieben Tage galten dem kostenlosen Personal Team und
|
||||
# gelten nicht mehr; ebensowenig das "keine Geraeteverwaltung auf
|
||||
# developer.apple.com", das daran hing.
|
||||
#
|
||||
# Der Export ueber -exportArchive ist erprobt und kein offener Punkt mehr: die
|
||||
# ausgelieferten .ipa vom 2026-08-29 und 2026-08-30 sind auf genau diesem Weg
|
||||
# entstanden und wurden ueber die Luft installiert (siehe ios-luftweg.sh).
|
||||
# Scheitert er doch einmal, bleibt der direkte Weg aufs angeschlossene Geraet:
|
||||
# xcodebuild -project ios/App/App.xcodeproj -scheme App \
|
||||
# -destination 'id=<UDID>' -allowProvisioningUpdates \
|
||||
# DEVELOPMENT_TEAM=$TEAM install
|
||||
set -euo pipefail
|
||||
|
||||
HIER="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
APP="$(dirname "$HIER")"
|
||||
# Dieselbe Kennung wie zu Zeiten des kostenlosen Kontos: die bezahlte
|
||||
# Mitgliedschaft hat das bestehende Team hochgestuft, statt ein neues
|
||||
# anzulegen. Zwischenzeitlich stand hier das Gegenteil - widerlegt am
|
||||
# 2026-08-29 durch das ausgestellte Profil (Team RMACS9VLS4, Laufzeit ein
|
||||
# Jahr statt sieben Tage). Ueberschreibbar ueber APPLE_TEAM_ID.
|
||||
TEAM="${APPLE_TEAM_ID:-RMACS9VLS4}"
|
||||
AUSGABE="$APP/ios/build"
|
||||
ARCHIV="$AUSGABE/DataMetric360.xcarchive"
|
||||
|
||||
cd "$APP"
|
||||
|
||||
# Ohne diese Pruefung baut/signiert das Skript zuverlaessig - aber nur den
|
||||
# Stand, der gerade lokal ausgecheckt ist. Ein vergessener Abgleich mit dem
|
||||
# Server waere damit ein stiller Veraltungsfehler wie schon mehrfach an anderer
|
||||
# Stelle in diesem Projekt (Cache-Busting, OTA-Buendel-Abgleich,
|
||||
# VERSIONIERUNG.md). Deshalb holt das Skript den Serverstand selbst und rueckt
|
||||
# den Klon zurecht, wo das verlustfrei ist - und bricht sonst laut ab, statt
|
||||
# einen optisch/inhaltlich alten Build auszuliefern.
|
||||
echo "==> Git-Stand pruefen"
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "FEHLER: Uncommittete Aenderungen im Arbeitsverzeichnis." >&2
|
||||
git status --short >&2
|
||||
echo "Erst committen oder stashen, dann erneut versuchen." >&2
|
||||
exit 1
|
||||
fi
|
||||
git fetch origin
|
||||
LOKAL="$(git rev-parse HEAD)"
|
||||
FERN="$(git rev-parse origin/main)"
|
||||
# Ziel dieses Blocks: der Klon steht danach auf origin/main - oder das Skript
|
||||
# bricht ab. Zurechtruecken darf es sich selbst, solange dabei nachweislich
|
||||
# nichts verlorengeht; der saubere Arbeitsbaum ist oben bereits Bedingung.
|
||||
if [ "$LOKAL" != "$FERN" ]; then
|
||||
BASIS="$(git merge-base "$LOKAL" "$FERN")"
|
||||
if git merge-base --is-ancestor "$LOKAL" "$FERN"; then
|
||||
echo " Klon war zurueck - wird vorgespult."
|
||||
git merge --ff-only "$FERN" >/dev/null
|
||||
LOKAL="$(git rev-parse HEAD)"
|
||||
elif git diff --quiet "$BASIS" "$LOKAL"; then
|
||||
# Hier liegen Commits, die origin/main nicht hat - aber ihr Tree ist derselbe
|
||||
# wie der des gemeinsamen Vorfahren, sie tragen inhaltlich also NICHTS bei.
|
||||
# Genau so sieht ein Klon aus, der noch vor einem Rewrite der Gegenseite steht
|
||||
# (am 07.09.2026 einmal geschehen). Ein 'git pull' waere hier falsch: der Merge
|
||||
# holt die entfernten Commits zurueck, und der naechste Push stellt sie auf dem
|
||||
# Server wieder her - sogar ohne --force. Verwerfen kostet dagegen keine einzige
|
||||
# Datei; das Reflog haelt sie noch 90 Tage.
|
||||
echo " Nur hier vorhanden, inhaltlich ohne Wirkung - wird verworfen:"
|
||||
git log --oneline "$BASIS..$LOKAL" | sed "s/^/ /"
|
||||
git reset --hard "$FERN" >/dev/null
|
||||
LOKAL="$(git rev-parse HEAD)"
|
||||
echo " Zurueckgesetzt auf origin/main. Rueckholbar ueber: git reflog"
|
||||
else
|
||||
echo "FEHLER: Lokaler Stand ($LOKAL) weicht von origin/main ($FERN) ab." >&2
|
||||
echo "" >&2
|
||||
echo "Diese Commits liegen nur hier und aendern auch den Inhalt:" >&2
|
||||
git log --oneline "$BASIS..$LOKAL" >&2
|
||||
echo "" >&2
|
||||
echo "Das sieht nach eigener, noch ungepushter Arbeit aus - deshalb entscheidet" >&2
|
||||
echo "das Skript hier nichts. Entweder pushen:" >&2
|
||||
echo " git push" >&2
|
||||
echo "oder verwerfen, falls die Gegenseite sie absichtlich entfernt hat:" >&2
|
||||
echo " git fetch origin && git reset --hard origin/main" >&2
|
||||
echo "" >&2
|
||||
echo "Kein 'git pull': ein Merge haengt die Commits wieder in die Historie." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo " HEAD == origin/main ($LOKAL), aktuell."
|
||||
|
||||
echo "==> Webbuendel bauen"
|
||||
npm run build
|
||||
|
||||
echo "==> Native Huelle synchronisieren"
|
||||
if [ ! -d ios ]; then
|
||||
echo "==> ios/ fehlt, wird neu erzeugt"
|
||||
npx cap add ios
|
||||
fi
|
||||
npx cap sync ios
|
||||
|
||||
# Standort-Berechtigung ins Info.plist eintragen.
|
||||
#
|
||||
# Ohne diesen Schluessel zeigt iOS beim Aufruf von navigator.geolocation in
|
||||
# der WebView (src/screens/Standort.tsx) ueberhaupt keine Rueckfrage: der
|
||||
# Aufruf scheitert sofort, die App meldet "GPS-Freigabe fehlt", und unter
|
||||
# Einstellungen > DataMetric360 > Ort fehlt die Auswahl "Beim Verwenden der
|
||||
# App" - es bleiben nur "Nie" und "Beim Teilen". Nachgewiesen am 2026-08-31
|
||||
# an der ausgelieferten App.ipa: kein einziger Location-Schluessel darin.
|
||||
#
|
||||
# Warum hier und nicht in Xcode: ios/ ist gitignored und wird von
|
||||
# `npx cap add ios` aus Capacitors Vorlage neu erzeugt - die kennt keine
|
||||
# Standort-Schluessel. Ein Eintrag von Hand in Xcode waere beim naechsten
|
||||
# Erzeugen wieder verschwunden, genau wie es der Team-Kennung oben ergangen
|
||||
# waere. Deshalb steht er versioniert hier und wird nach jedem `cap sync`
|
||||
# neu gesetzt.
|
||||
#
|
||||
# Nur "WhenInUse": berechnet wird der Abstand zum geparkten Fahrzeug, waehrend
|
||||
# jemand die Standort-Seite ansieht. Hintergrundortung braucht die App nicht,
|
||||
# und was sie nicht braucht, soll sie auch nicht erfragen.
|
||||
echo "==> Standort-Berechtigung eintragen"
|
||||
PLIST="ios/App/App/Info.plist"
|
||||
GRUND="Zeigt auf der Standort-Karte, wie weit du vom geparkten Fahrzeug entfernt bist."
|
||||
if ! /usr/libexec/PlistBuddy -c "Set :NSLocationWhenInUseUsageDescription $GRUND" "$PLIST" 2>/dev/null; then
|
||||
/usr/libexec/PlistBuddy -c "Add :NSLocationWhenInUseUsageDescription string $GRUND" "$PLIST"
|
||||
fi
|
||||
/usr/libexec/PlistBuddy -c "Print :NSLocationWhenInUseUsageDescription" "$PLIST" >/dev/null || {
|
||||
echo "FEHLER: NSLocationWhenInUseUsageDescription liess sich nicht setzen." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Kalender-Berechtigung, aus demselben Grund an derselben Stelle.
|
||||
#
|
||||
# Warum ZWEI Schluessel: seit iOS 17 gibt es einen eigenen Nur-Schreiben-Zugriff
|
||||
# (NSCalendarsWriteOnlyAccessUsageDescription), aeltere Fassungen kennen nur den
|
||||
# alten Sammelschluessel. Der System-Editor (native/KalenderTermin.swift) fragt
|
||||
# auf iOS 17+ von sich aus gar nicht - die Schluessel sind die Absicherung fuer
|
||||
# den Fall, dass EventKit doch einmal fragt. Fehlt der passende, beendet iOS die
|
||||
# App wortlos, statt eine Rueckfrage zu zeigen.
|
||||
echo "==> Kalender-Berechtigung eintragen"
|
||||
KALENDERGRUND="Traegt Werkstatt- und Wechseltermine in deinen Kalender ein."
|
||||
for SCHLUESSEL in NSCalendarsWriteOnlyAccessUsageDescription NSCalendarsUsageDescription; do
|
||||
if ! /usr/libexec/PlistBuddy -c "Set :$SCHLUESSEL $KALENDERGRUND" "$PLIST" 2>/dev/null; then
|
||||
/usr/libexec/PlistBuddy -c "Add :$SCHLUESSEL string $KALENDERGRUND" "$PLIST"
|
||||
fi
|
||||
/usr/libexec/PlistBuddy -c "Print :$SCHLUESSEL" "$PLIST" >/dev/null || {
|
||||
echo "FEHLER: $SCHLUESSEL liess sich nicht setzen." >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
# Kamera, fuer den QR-Scanner der Ersteinrichtung.
|
||||
#
|
||||
# Gelesen wird der Code in der WebView (jsQR ueber getUserMedia), nicht von
|
||||
# einem nativen Plugin - siehe den Kopf von src/screens/QrScanner.tsx, warum
|
||||
# nicht GoogleMLKit. Dieser Schluessel ist trotzdem noetig: ohne ihn beendet
|
||||
# iOS die App in dem Moment, in dem sie die Kamera anfordert.
|
||||
echo "==> Kamera-Berechtigung eintragen"
|
||||
KAMERAGRUND="Liest den QR-Code deines Zugangs-Tokens bei der Ersteinrichtung."
|
||||
if ! /usr/libexec/PlistBuddy -c "Set :NSCameraUsageDescription $KAMERAGRUND" "$PLIST" 2>/dev/null; then
|
||||
/usr/libexec/PlistBuddy -c "Add :NSCameraUsageDescription string $KAMERAGRUND" "$PLIST"
|
||||
fi
|
||||
/usr/libexec/PlistBuddy -c "Print :NSCameraUsageDescription" "$PLIST" >/dev/null || {
|
||||
echo "FEHLER: NSCameraUsageDescription liess sich nicht setzen." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Share-Erweiterung ins Xcode-Projekt haengen.
|
||||
#
|
||||
# Aus demselben Grund wie die Team-Kennung und der Standort-Schluessel oben:
|
||||
# ios/ ist gitignored, also verschwindet jedes von Hand angelegte Ziel beim
|
||||
# naechsten `npx cap add ios`. Die Quellen liegen versioniert unter native/,
|
||||
# das Skript spielt sie bei jedem Bau wieder ein. Es ist idempotent - ein
|
||||
# zweiter Lauf ueber ein fertiges Projekt aendert nichts.
|
||||
#
|
||||
# Schlaegt es fehl, bricht der ganze Bau ab: eine App ohne den Eintrag im
|
||||
# Teilen-Blatt sieht fertig aus und ist es nicht, und der Unterschied faellt
|
||||
# erst auf dem Geraet auf.
|
||||
echo "==> Share-Erweiterung einrichten"
|
||||
node scripts/ios-teilen-einrichten.mjs
|
||||
|
||||
# App-Symbol einspielen.
|
||||
#
|
||||
# Capacitors Vorlage bringt ein eigenes Platzhalter-Symbol mit, und ios/ ist
|
||||
# gitignored - ein in Xcode eingesetztes Symbol waere beim naechsten
|
||||
# `npx cap add ios` wieder weg. Die Vorlage liegt deshalb versioniert unter
|
||||
# native/AppIcon.appiconset und wird hier ueber die von Capacitor erzeugte
|
||||
# gelegt.
|
||||
#
|
||||
# Drei Ausfuehrungen, wie iOS 18 sie erwartet: hell mit eigenem Grund, dunkel
|
||||
# und getoent ohne - dort legt das System seinen eigenen Grund darunter, und
|
||||
# ein mitgeliefertes Schwarz bliebe darauf als Kasten sichtbar.
|
||||
echo "==> App-Symbol einspielen"
|
||||
SYMBOLZIEL="ios/App/App/Assets.xcassets/AppIcon.appiconset"
|
||||
rm -rf "$SYMBOLZIEL"
|
||||
mkdir -p "$SYMBOLZIEL"
|
||||
cp -R native/AppIcon.appiconset/. "$SYMBOLZIEL"/
|
||||
[ -f "$SYMBOLZIEL/Contents.json" ] || { echo "FEHLER: App-Symbol nicht eingespielt." >&2; exit 1; }
|
||||
echo " hell, dunkel, getoent"
|
||||
|
||||
# Versionsnummer aus der manifest.json in die App schreiben.
|
||||
#
|
||||
# Capacitors Vorlage setzt 1.0/1 und laesst es dabei - zwei nacheinander
|
||||
# aufgespielte .ipa sind auf dem Geraet dadurch nicht zu unterscheiden, und
|
||||
# man weiss nicht, welche gerade drauf ist. Dieselbe Zahl, die schon Panel,
|
||||
# OTA-Buendel und __APP_VERSION__ tragen (siehe VERSIONIERUNG.md): eine
|
||||
# Quelle, kein zweiter Ort zum Vergessen.
|
||||
echo "==> Versionsnummer eintragen"
|
||||
VERSION="$(node -p "require('../custom_components/audi_dashboard/manifest.json').version")"
|
||||
[ -n "$VERSION" ] || { echo "FEHLER: keine Version in der manifest.json." >&2; exit 1; }
|
||||
if ! /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$PLIST" 2>/dev/null; then
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string $VERSION" "$PLIST"
|
||||
fi
|
||||
BUILDNR="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$PLIST" 2>/dev/null || echo 1)"
|
||||
# In der Info.plist der App steht dort KEINE Zahl, sondern der Platzhalter
|
||||
# einer Build-Einstellung ($(CURRENT_PROJECT_VERSION), aus dem Capacitor-
|
||||
# Geruest). Fuer das App-Ziel loest Xcode ihn zu 1 auf - das Ziel der
|
||||
# Erweiterung kennt die Einstellung aber gar nicht, dort wurde daraus LEER,
|
||||
# und ein Buendel ohne CFBundleVersion lehnt iOS beim Installieren ab
|
||||
# ("kann nicht installiert werden"). Genau so ist die .ipa vom 04.09.2026
|
||||
# unbrauchbar geworden - an der ausgelieferten Datei nachgemessen: App
|
||||
# CFBundleVersion 1, Erweiterung gar keine. Deshalb hier nur eine echte Zahl
|
||||
# weiterreichen, sonst die 1.
|
||||
case "$BUILDNR" in
|
||||
""|*'$('*|*[!0-9.]*) BUILDNR=1 ;;
|
||||
esac
|
||||
|
||||
# Die Share-Erweiterung MUSS dieselben beiden Nummern tragen wie die App.
|
||||
#
|
||||
# Apples App Extension Programming Guide verlangt das ausdruecklich, und iOS
|
||||
# haelt sich daran: stimmen CFBundleShortVersionString und CFBundleVersion
|
||||
# nicht ueberein, registriert das System die Erweiterung nicht - sie taucht im
|
||||
# Teilen-Blatt dann gar nicht erst auf, ohne jede Fehlermeldung. Genau das war
|
||||
# in der .ipa vom 03.09.2026 der Fall: App 2026.9.3.3, Erweiterung 1.0 (an der
|
||||
# ausgelieferten Datei nachgemessen, nicht vermutet - native/teilen/Info.plist
|
||||
# traegt die 1.0 fest, und dieser Schritt schrieb bis dahin nur $PLIST).
|
||||
ERW_PLIST="ios/App/DataMetric360Share/Info.plist"
|
||||
if [ -f "$ERW_PLIST" ]; then
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$ERW_PLIST"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILDNR" "$ERW_PLIST"
|
||||
echo " $VERSION ($BUILDNR), App und Erweiterung"
|
||||
else
|
||||
echo " $VERSION ($BUILDNR) - Erweiterung nicht gefunden, nur die App"
|
||||
fi
|
||||
|
||||
# Zwischengespeicherte Bereitstellungsprofile dieser App wegraeumen.
|
||||
#
|
||||
# `-allowProvisioningUpdates` zieht ein neues Profil nur, wenn kein passendes
|
||||
# herumliegt - ein vorhandenes wird stillschweigend weiterbenutzt, auch wenn
|
||||
# sich seither die Berechtigungen geaendert haben. Genau daran ist am
|
||||
# 2026-08-29 eine Installation gescheitert ("Integritaet konnte nicht
|
||||
# geprueft werden"): das eingebettete Profil kannte ein Geraet, das Team
|
||||
# hatte inzwischen zwei. Mit der App-Gruppe der Share-Erweiterung ist
|
||||
# derselbe Fall erneut eingetreten - jedes vor dem 2026-08-31 gezogene
|
||||
# Profil kennt sie nicht.
|
||||
#
|
||||
# Weggeraeumt wird nur, was zu dieser App gehoert (Bundle-ID im entpackten
|
||||
# Profil gesucht), nicht der ganze Ordner - dort liegen auch die Profile
|
||||
# anderer Projekte. Mit --profile-behalten laesst sich der Schritt
|
||||
# ueberspringen.
|
||||
if [ "${1:-}" != "--profile-behalten" ]; then
|
||||
echo "==> Alte Bereitstellungsprofile wegraeumen"
|
||||
PROFILE="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"
|
||||
if [ -d "$PROFILE" ]; then
|
||||
ANZAHL=0
|
||||
for datei in "$PROFILE"/*.mobileprovision; do
|
||||
[ -e "$datei" ] || continue
|
||||
if security cms -D -i "$datei" 2>/dev/null | grep -qF "app.datametric360"; then
|
||||
rm -f "$datei"
|
||||
ANZAHL=$((ANZAHL + 1))
|
||||
fi
|
||||
done
|
||||
echo " $ANZAHL entfernt - xcodebuild zieht sie gleich neu"
|
||||
else
|
||||
echo " keine vorhanden"
|
||||
fi
|
||||
else
|
||||
echo "==> Bereitstellungsprofile bleiben (--profile-behalten)"
|
||||
fi
|
||||
|
||||
echo "==> Archiv bauen und signieren (Team $TEAM)"
|
||||
rm -rf "$AUSGABE"
|
||||
mkdir -p "$AUSGABE"
|
||||
xcodebuild \
|
||||
-project ios/App/App.xcodeproj \
|
||||
-scheme App \
|
||||
-configuration Release \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-archivePath "$ARCHIV" \
|
||||
-allowProvisioningUpdates \
|
||||
DEVELOPMENT_TEAM="$TEAM" \
|
||||
archive
|
||||
|
||||
# Ad-hoc, nicht development: das ist die von Apple vorgesehene Art fuer die
|
||||
# Installation ueber die Luft (siehe ios-luftweg.sh) und deckt den Weg per
|
||||
# Kabel gleich mit ab. Sie verlangt ein Apple-Distribution-Zertifikat, das
|
||||
# -allowProvisioningUpdates bei Bedarf selbst anlegt - moeglich erst seit der
|
||||
# bezahlten Mitgliedschaft. Die Signatur laeuft damit ein Jahr.
|
||||
cat > "$AUSGABE/ExportOptions.plist" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key><string>ad-hoc</string>
|
||||
<key>teamID</key><string>$TEAM</string>
|
||||
<key>signingStyle</key><string>automatic</string>
|
||||
<key>stripSwiftSymbols</key><true/>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
echo "==> .ipa exportieren"
|
||||
xcodebuild -exportArchive \
|
||||
-archivePath "$ARCHIV" \
|
||||
-exportOptionsPlist "$AUSGABE/ExportOptions.plist" \
|
||||
-exportPath "$AUSGABE" \
|
||||
-allowProvisioningUpdates
|
||||
|
||||
|
||||
# Am FERTIGEN Buendel gegenlesen, nicht am Projekt.
|
||||
#
|
||||
# Abschnitt AK der AGENTS.md: von fuenf Fehlern in der nativen Huelle haben
|
||||
# vier einen erfolgreichen Archivlauf erzeugt und sind erst am entpackten
|
||||
# .ipa aufgefallen. Geprueft wird hier genau das, was still schiefgehen kann:
|
||||
# eine Erweiterung ohne Programm, ohne App-Gruppe, oder mit abweichender
|
||||
# Versionsnummer (dann registriert iOS sie nicht und sie fehlt im
|
||||
# Teilen-Blatt).
|
||||
echo "==> Fertige .ipa gegenlesen"
|
||||
PRUEF="$(mktemp -d)"
|
||||
if unzip -q "$AUSGABE/App.ipa" -d "$PRUEF"; then
|
||||
ERW="$PRUEF/Payload/App.app/PlugIns/DataMetric360Share.appex"
|
||||
if [ ! -x "$ERW/DataMetric360Share" ] && [ ! -f "$ERW/DataMetric360Share" ]; then
|
||||
echo " WARNUNG: die Erweiterung hat kein Programm - sie wird nichts tun."
|
||||
fi
|
||||
ERW_V="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$ERW/Info.plist" 2>/dev/null || echo '?')"
|
||||
APP_V="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$PRUEF/Payload/App.app/Info.plist" 2>/dev/null || echo '?')"
|
||||
if [ "$ERW_V" != "$APP_V" ]; then
|
||||
echo " WARNUNG: Erweiterung $ERW_V, App $APP_V - iOS registriert die"
|
||||
echo " Erweiterung dann nicht, sie fehlt im Teilen-Blatt."
|
||||
else
|
||||
echo " Erweiterung und App tragen beide $APP_V"
|
||||
fi
|
||||
# CFBundleVersion ist Pflicht. Fehlt sie in EINEM der beiden Buendel,
|
||||
# verweigert iOS die Installation der ganzen App - ohne Hinweis darauf,
|
||||
# woran es liegt. Deshalb hart pruefen, nicht nur warnen.
|
||||
for ZIEL in "$PRUEF/Payload/App.app" "$ERW"; do
|
||||
BV="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$ZIEL/Info.plist" 2>/dev/null || echo '')"
|
||||
case "$BV" in
|
||||
""|*'$('*)
|
||||
echo " FEHLER: $(basename "$ZIEL") hat keine brauchbare CFBundleVersion ('$BV')." >&2
|
||||
echo " iOS wuerde die Installation ablehnen - Bau abgebrochen." >&2
|
||||
rm -rf "$PRUEF"
|
||||
exit 1 ;;
|
||||
esac
|
||||
done
|
||||
for ZIEL in "$PRUEF/Payload/App.app" "$ERW"; do
|
||||
if ! codesign -d --entitlements :- "$ZIEL" 2>/dev/null | grep -q "group.app.datametric360"; then
|
||||
echo " WARNUNG: $(basename "$ZIEL") ohne App-Gruppe - Teilen kann nichts uebergeben."
|
||||
fi
|
||||
done
|
||||
# Eigene Plugins muessen in packageClassList stehen. Capacitor 8 sucht
|
||||
# Plugin-Klassen NICHT im Programm (registerPlugins() in
|
||||
# CapacitorBridge.swift liest nur diese Liste) - eine einkompilierte
|
||||
# Klasse, die dort fehlt, existiert fuer die Bruecke nicht. Genau so kam
|
||||
# die .ipa vom 04.09.2026 heraus: Zwischenablage und Kalendertermin waren
|
||||
# im Programm und trotzdem tot.
|
||||
KONF="$PRUEF/Payload/App.app/capacitor.config.json"
|
||||
for KLASSE in $(grep -ho "@objc([A-Za-z0-9_]*)" "$APP/native"/*.swift | sed "s/@objc(//;s/)//"); do
|
||||
if ! grep -q "$KLASSE" "$KONF" 2>/dev/null; then
|
||||
echo " FEHLER: $KLASSE fehlt in packageClassList - das Plugin bliebe stumm." >&2
|
||||
rm -rf "$PRUEF"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo " packageClassList: eigene Plugins eingetragen"
|
||||
fi
|
||||
rm -rf "$PRUEF"
|
||||
|
||||
# Erst JETZT ablegen. auslieferung/ ist versioniert und wird ausgeliefert -
|
||||
# eine Datei, die die Pruefung oben nicht besteht, darf dort nicht landen und
|
||||
# schon gar nicht committet werden. (ios/ ist gitignored, sonst existierte das
|
||||
# Ergebnis nur auf diesem einen Mac.)
|
||||
mkdir -p "$APP/auslieferung"
|
||||
cp "$AUSGABE/App.ipa" "$APP/auslieferung/App.ipa"
|
||||
|
||||
# Auslieferung ueber die Luft gleich mitschreiben.
|
||||
#
|
||||
# manifest.plist und die Installationsseite hingen bisher an einem zweiten,
|
||||
# von Hand aufzurufenden Skript - und ohne sie ist die frische .ipa auf dem
|
||||
# Telefon nicht installierbar, obwohl sie fertig im Repo liegt. Ein Schritt,
|
||||
# den man vergessen kann, gehoert in den Bau.
|
||||
#
|
||||
# Die Basis-Adresse ist die des Gitea-Repos: es liefert die Dateien bereits
|
||||
# ueber HTTPS mit oeffentlich vertrauenswuerdigem Zertifikat aus, was iOS
|
||||
# fuer diesen Weg zwingend verlangt (siehe ios-luftweg.sh). Ueberschreibbar
|
||||
# ueber LUFTWEG_BASIS, falls der Weg einmal ueber einen eigenen Host geht.
|
||||
echo "==> Auslieferung ueber die Luft vorbereiten"
|
||||
LUFTWEG_BASIS="${LUFTWEG_BASIS:-https://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/companion-app/auslieferung}"
|
||||
bash "$HIER/ios-luftweg.sh" "$LUFTWEG_BASIS" auslieferung
|
||||
|
||||
echo
|
||||
echo "Fertig: $APP/auslieferung/App.ipa"
|
||||
echo "Version: $VERSION"
|
||||
echo
|
||||
echo "Noch zu tun:"
|
||||
echo " 1. git add companion-app/auslieferung && git commit && git push"
|
||||
echo " (die .ipa und manifest.plist muessen im Repo liegen, sonst kann"
|
||||
echo " das Telefon sie nicht laden)"
|
||||
echo " 2. Auf dem iPhone in Safari oeffnen:"
|
||||
echo " itms-services://?action=download-manifest&url=$LUFTWEG_BASIS/manifest.plist"
|
||||
echo
|
||||
echo "Per Kabel stattdessen: xcrun devicectl device install app --device <UDID> $APP/auslieferung/App.ipa"
|
||||
@@ -0,0 +1,311 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Hängt die Share-Erweiterung ins erzeugte Xcode-Projekt.
|
||||
*
|
||||
* WARUM ES DIESES SKRIPT GIBT
|
||||
* ---------------------------
|
||||
* `companion-app/ios/` ist absichtlich gitignored — `npx cap add ios` baut den
|
||||
* Ordner aus dem Webbündel jederzeit neu. Damit verschwindet aber auch jedes
|
||||
* Ziel, das man in Xcode von Hand anlegt. Dieselbe Falle, die schon die
|
||||
* Team-Kennung und den Standort-Schlüssel in `ios-signieren.sh` getrieben hat:
|
||||
* was den nächsten `cap add` überleben soll, muss versioniert sein und bei
|
||||
* jedem Bau neu eingespielt werden.
|
||||
*
|
||||
* Die Quellen liegen deshalb unter `companion-app/native/`; dieses Skript
|
||||
* kopiert sie nach `ios/App/` und trägt das Erweiterungsziel in die
|
||||
* `project.pbxproj` ein.
|
||||
*
|
||||
* WAS ES ANLEGT
|
||||
* -------------
|
||||
* - Ziel `DataMetric360Share` (App-Erweiterung, Bundle-ID
|
||||
* `app.datametric360.teilen`)
|
||||
* - dessen Quelldateien, Info.plist und Entitlements
|
||||
* - die App-Gruppe `group.app.datametric360` auf BEIDEN Zielen
|
||||
* - die Einbettung der Erweiterung in die App (Build-Phase
|
||||
* „Embed App Extensions")
|
||||
* - das URL-Schema `datametric360://`, über das die Erweiterung die App holt
|
||||
*
|
||||
* IDEMPOTENT
|
||||
* ----------
|
||||
* Ein zweiter Lauf über ein bereits eingerichtetes Projekt ändert nichts und
|
||||
* meldet das. Wichtig, weil `ios-signieren.sh` bei jedem Bau durchläuft und
|
||||
* `ios/` nicht immer neu erzeugt wird.
|
||||
*
|
||||
* NICHT GETESTET AUF EINEM MAC
|
||||
* ----------------------------
|
||||
* Geschrieben ohne Zugriff auf macOS/Xcode. Die pbxproj-Bearbeitung folgt der
|
||||
* API des npm-Pakets `xcode`; scheitert sie, bricht das Skript mit einer
|
||||
* klaren Meldung ab und `docs/SHARE_EXTENSION.md` beschreibt die Handgriffe,
|
||||
* die dasselbe in Xcode erledigen. Lieber ein lauter Abbruch mit Anleitung
|
||||
* als ein halb eingerichtetes Projekt.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
import xcode from "xcode"
|
||||
|
||||
const HIER = dirname(fileURLToPath(import.meta.url))
|
||||
const APP = dirname(HIER)
|
||||
|
||||
const ZIEL_NAME = "DataMetric360Share"
|
||||
const BUNDLE_ID = "app.datametric360.teilen"
|
||||
const GRUPPE = "group.app.datametric360"
|
||||
|
||||
/* Eigene Capacitor-Plugins der App - je eine Swift-Datei, die neben der
|
||||
Erweiterung ins App-Ziel gehört. Sie liegen aus demselben Grund unter
|
||||
native/ wie alles andere hier: ios/ ist gitignored und wird neu erzeugt. */
|
||||
const PLUGIN_DATEIEN = ["BelegZwischenablage.swift", "KalenderTermin.swift"]
|
||||
|
||||
/** pbxproj-Wert ohne die optionalen Anfuehrungszeichen. */
|
||||
const entquotet = (wert) => String(wert ?? "").replace(/^"|"$/g, "")
|
||||
const SCHEMA = "datametric360"
|
||||
|
||||
const iosApp = join(APP, "ios", "App")
|
||||
const pbxPfad = join(iosApp, "App.xcodeproj", "project.pbxproj")
|
||||
const quelleErw = join(APP, "native", "teilen")
|
||||
const zielErw = join(iosApp, ZIEL_NAME)
|
||||
|
||||
function melden(text) {
|
||||
console.log(" " + text)
|
||||
}
|
||||
|
||||
function abbruch(text) {
|
||||
console.error("")
|
||||
console.error("FEHLER: " + text)
|
||||
console.error("")
|
||||
console.error("Die Share-Erweiterung ist damit NICHT eingerichtet. Die App selbst")
|
||||
console.error("baut und läuft weiterhin - nur der Eintrag im Teilen-Blatt fehlt.")
|
||||
console.error("Von Hand nachziehen: companion-app/docs/SHARE_EXTENSION.md")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!existsSync(pbxPfad)) {
|
||||
abbruch(`${pbxPfad} nicht gefunden - erst 'npx cap sync ios' laufen lassen.`)
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- Dateien */
|
||||
|
||||
mkdirSync(zielErw, { recursive: true })
|
||||
for (const datei of ["ShareViewController.swift", "Info.plist", `${ZIEL_NAME}.entitlements`]) {
|
||||
copyFileSync(join(quelleErw, datei), join(zielErw, datei))
|
||||
}
|
||||
copyFileSync(join(APP, "native", "App.entitlements"), join(iosApp, "App", "App.entitlements"))
|
||||
for (const datei of PLUGIN_DATEIEN) {
|
||||
copyFileSync(join(APP, "native", datei), join(iosApp, "App", datei))
|
||||
}
|
||||
melden(`Quellen nach ios/App/${ZIEL_NAME}/ kopiert`)
|
||||
|
||||
/* ------------------------------------------ Eigene Plugins registrieren */
|
||||
|
||||
/* Capacitor 8 sucht Plugin-Klassen NICHT im Programm. `registerPlugins()`
|
||||
in CapacitorBridge.swift liest ausschliesslich `packageClassList` aus der
|
||||
capacitor.config.json des Buendels und loest jeden Eintrag ueber
|
||||
NSClassFromString auf - was dort nicht steht, existiert fuer die Bruecke
|
||||
nicht, auch wenn die Klasse einkompiliert ist.
|
||||
|
||||
Genau daran sind beide eigenen Plugins gescheitert: `cap sync` erzeugt die
|
||||
Liste aus den installierten npm-Paketen, und unsere Plugins sind keine.
|
||||
In der ausgelieferten .ipa vom 04.09.2026 standen dort nur die sechs
|
||||
Fremd-Plugins - die Zwischenablage meldete deshalb 'diese App-Fassung
|
||||
enthaelt den nativen Teil noch nicht' (Nutzerbefund), obwohl
|
||||
BelegZwischenablagePlugin nachweislich im Programm lag, und der
|
||||
Kalendertermin fiel still auf das Teilen-Blatt zurueck, das den Kalender
|
||||
gar nicht erreichen kann (siehe AGENTS.md Abschnitt BS).
|
||||
|
||||
Die Klassennamen kommen aus den Swift-Dateien selbst (@objc(...)), damit
|
||||
sie nicht an zwei Stellen gepflegt werden muessen. */
|
||||
const konfigPfad = join(iosApp, "App", "capacitor.config.json")
|
||||
if (!existsSync(konfigPfad)) {
|
||||
abbruch(`${konfigPfad} nicht gefunden - erst 'npx cap sync ios' laufen lassen.`)
|
||||
}
|
||||
const eigeneKlassen = PLUGIN_DATEIEN.map((datei) => {
|
||||
const quelle = readFileSync(join(APP, "native", datei), "utf8")
|
||||
const treffer = /@objc\(([A-Za-z0-9_]+)\)/.exec(quelle)
|
||||
if (!treffer) abbruch(`${datei}: kein @objc(...)-Name gefunden`)
|
||||
return treffer[1]
|
||||
})
|
||||
const konfig = JSON.parse(readFileSync(konfigPfad, "utf8"))
|
||||
konfig.packageClassList = konfig.packageClassList ?? []
|
||||
const fehlten = eigeneKlassen.filter((k) => !konfig.packageClassList.includes(k))
|
||||
if (fehlten.length) {
|
||||
konfig.packageClassList.push(...fehlten)
|
||||
writeFileSync(konfigPfad, JSON.stringify(konfig, null, 2) + String.fromCharCode(10))
|
||||
}
|
||||
melden(`packageClassList: ${eigeneKlassen.join(", ")} eingetragen`)
|
||||
|
||||
/* ------------------------------------------------------------- URL-Schema */
|
||||
|
||||
/* Ohne dieses Schema kann die Erweiterung die App nicht holen - sie legt den
|
||||
Beleg zwar ab, aber niemand merkt es, bis die App das nächste Mal von Hand
|
||||
geöffnet wird. */
|
||||
const appPlistPfad = join(iosApp, "App", "Info.plist")
|
||||
let appPlist = readFileSync(appPlistPfad, "utf8")
|
||||
if (!appPlist.includes("CFBundleURLTypes")) {
|
||||
const eintrag = [
|
||||
"\t<key>CFBundleURLTypes</key>",
|
||||
"\t<array>",
|
||||
"\t\t<dict>",
|
||||
"\t\t\t<key>CFBundleURLName</key>",
|
||||
`\t\t\t<string>app.datametric360</string>`,
|
||||
"\t\t\t<key>CFBundleURLSchemes</key>",
|
||||
"\t\t\t<array>",
|
||||
`\t\t\t\t<string>${SCHEMA}</string>`,
|
||||
"\t\t\t</array>",
|
||||
"\t\t</dict>",
|
||||
"\t</array>",
|
||||
"</dict>",
|
||||
].join("\n")
|
||||
const letztes = appPlist.lastIndexOf("</dict>")
|
||||
if (letztes < 0) abbruch("ios/App/App/Info.plist hat keine schließende <dict>-Marke.")
|
||||
appPlist = appPlist.slice(0, letztes) + eintrag + appPlist.slice(letztes + "</dict>".length)
|
||||
writeFileSync(appPlistPfad, appPlist, "utf8")
|
||||
melden(`URL-Schema ${SCHEMA}:// eingetragen`)
|
||||
} else {
|
||||
melden("URL-Schema war bereits eingetragen")
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- Projekt */
|
||||
|
||||
const projekt = xcode.project(pbxPfad)
|
||||
projekt.parseSync()
|
||||
|
||||
/* ------------------------------------------------ Eigene Plugins (App-Ziel)
|
||||
|
||||
Steht VOR dem Ausstieg unten: die Erweiterung ist nach dem ersten Lauf
|
||||
vorhanden, die Plugins müssen trotzdem bei jedem Bau geprüft werden - ios/
|
||||
wird von `npx cap add ios` jederzeit neu erzeugt.
|
||||
|
||||
`addSourceFile` ohne Gruppe hängt die Datei an die ERSTE Sources-Phase des
|
||||
Projekts, und das ist die des App-Ziels - genau die richtige. (Mit Gruppe
|
||||
landete die Datei nur im Navigator; daran ist am 02.09.2026 die Erweiterung
|
||||
ohne Programm entstanden, siehe unten.)
|
||||
|
||||
Ohne Gruppe geht `addSourceFile` intern über `addPluginFile`, und das legt
|
||||
die Datei in eine Projektgruppe namens "Plugins". Ein Capacitor-Projekt hat
|
||||
die nicht, `pbxGroupByName` liefert null, und das Skript stirbt mit
|
||||
"Cannot read properties of null (reading 'path')" (03.09.2026). Die Gruppe
|
||||
wird deshalb angelegt, falls sie fehlt - sie ist reine Ablage im Navigator
|
||||
und ändert am Bau nichts. */
|
||||
if (!projekt.pbxGroupByName("Plugins")) {
|
||||
projekt.addPbxGroup([], "Plugins", '""')
|
||||
melden("Projektgruppe Plugins angelegt (von addSourceFile vorausgesetzt)")
|
||||
}
|
||||
|
||||
let pluginsGeaendert = false
|
||||
for (const datei of PLUGIN_DATEIEN) {
|
||||
if (JSON.stringify(projekt.pbxSourcesBuildPhaseObj()).includes(datei)) {
|
||||
melden(`${datei} war bereits im App-Ziel`)
|
||||
continue
|
||||
}
|
||||
projekt.addSourceFile(`App/${datei}`)
|
||||
melden(`${datei} ins App-Ziel gehängt`)
|
||||
pluginsGeaendert = true
|
||||
}
|
||||
if (pluginsGeaendert) {
|
||||
writeFileSync(pbxPfad, projekt.writeSync())
|
||||
// Nach dem Schreiben neu einlesen: der Rest des Skripts arbeitet sonst auf
|
||||
// einem Stand, der die eigene Änderung nicht kennt.
|
||||
projekt.parseSync()
|
||||
}
|
||||
|
||||
const vorhanden = Object.values(projekt.pbxNativeTargetSection()).some(
|
||||
(z) => z && typeof z === "object" && z.name === ZIEL_NAME,
|
||||
)
|
||||
if (vorhanden) {
|
||||
melden(`Ziel ${ZIEL_NAME} war bereits im Projekt - nichts geändert`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
let ziel
|
||||
try {
|
||||
ziel = projekt.addTarget(ZIEL_NAME, "app_extension", ZIEL_NAME, BUNDLE_ID)
|
||||
|
||||
// Eigene Build-Phasen: ohne sie hat das Ziel keine Quelldateien und keine
|
||||
// Ressourcen, und Xcode baut ein leeres Bündel.
|
||||
//
|
||||
// Die Quelle gehoert hier hinein und nicht in ein spaeteres addSourceFile:
|
||||
// reicht man diesem eine Gruppe mit, legt es die Datei nur in die Gruppe
|
||||
// (Navigator) und haengt sie an keine Sources-Phase. Das Ergebnis war ein
|
||||
// .appex ganz ohne Programm - nur Info.plist und Signatur, obwohl
|
||||
// CFBundleExecutable eines versprach (2026-09-02 an der fertigen .ipa
|
||||
// gemessen). Xcode meldet dabei nichts; der Bau gilt als erfolgreich.
|
||||
// Pfad relativ zum Projektordner (ios/App), nicht nur der Dateiname: ohne
|
||||
// Gruppe loest Xcode den Namen gegen die Projektwurzel auf und sucht dann
|
||||
// ios/App/ShareViewController.swift - "Build input file cannot be found".
|
||||
projekt.addBuildPhase(
|
||||
[`${ZIEL_NAME}/ShareViewController.swift`],
|
||||
"PBXSourcesBuildPhase",
|
||||
"Sources",
|
||||
ziel.uuid,
|
||||
)
|
||||
projekt.addBuildPhase([], "PBXResourcesBuildPhase", "Resources", ziel.uuid)
|
||||
projekt.addBuildPhase([], "PBXFrameworksBuildPhase", "Frameworks", ziel.uuid)
|
||||
|
||||
// Nur zur Anzeige in Xcode. Die Quelle ist oben bereits der Sources-Phase
|
||||
// zugeordnet und steht deshalb hier nicht noch einmal.
|
||||
const gruppe = projekt.addPbxGroup(
|
||||
["Info.plist", `${ZIEL_NAME}.entitlements`],
|
||||
ZIEL_NAME,
|
||||
ZIEL_NAME,
|
||||
)
|
||||
// In die Projektwurzel einhängen, sonst taucht die Gruppe in Xcode nicht auf.
|
||||
const wurzel = Object.keys(projekt.hash.project.objects.PBXGroup).find(
|
||||
(k) => projekt.hash.project.objects.PBXGroup[k].name === "CustomTemplate",
|
||||
)
|
||||
if (wurzel) projekt.addToPbxGroup(gruppe.uuid, wurzel)
|
||||
|
||||
// Einstellungen, die `addTarget` nicht selbst setzt.
|
||||
const konfigs = projekt.pbxXCBuildConfigurationSection()
|
||||
for (const schluessel of Object.keys(konfigs)) {
|
||||
const eintrag = konfigs[schluessel]
|
||||
if (!eintrag || typeof eintrag !== "object" || !eintrag.buildSettings) continue
|
||||
const s = eintrag.buildSettings
|
||||
// Ohne Anfuehrungszeichen vergleichen: die pbxproj fuehrt Werte mal mit,
|
||||
// mal ohne. Was `addTarget` schreibt, ist gequotet ("app.datametric360.
|
||||
// teilen"), was Capacitor fuer die App erzeugt, steht nackt da
|
||||
// (app.datametric360). Der Vergleich auf die gequotete Form traf deshalb
|
||||
// die App nie - sie wurde ohne App-Gruppe signiert, waehrend die
|
||||
// Erweiterung sie hatte. Ergebnis waere ein stiller Ausfall gewesen:
|
||||
// teilen meldet Erfolg, die App findet nichts (2026-09-02 an der
|
||||
// fertigen .ipa gemessen - Entitlements der App ohne
|
||||
// application-groups).
|
||||
if (entquotet(s.PRODUCT_BUNDLE_IDENTIFIER) === BUNDLE_ID || entquotet(s.PRODUCT_NAME) === ZIEL_NAME) {
|
||||
s.INFOPLIST_FILE = `"${ZIEL_NAME}/Info.plist"`
|
||||
s.CODE_SIGN_ENTITLEMENTS = `"${ZIEL_NAME}/${ZIEL_NAME}.entitlements"`
|
||||
s.IPHONEOS_DEPLOYMENT_TARGET = "14.0"
|
||||
s.SWIFT_VERSION = "5.0"
|
||||
s.TARGETED_DEVICE_FAMILY = `"1,2"`
|
||||
s.SKIP_INSTALL = "YES"
|
||||
}
|
||||
// Die App selbst braucht ihre neue Entitlements-Datei.
|
||||
if (entquotet(s.PRODUCT_BUNDLE_IDENTIFIER) === "app.datametric360") {
|
||||
s.CODE_SIGN_ENTITLEMENTS = `"App/App.entitlements"`
|
||||
}
|
||||
}
|
||||
|
||||
// Eingebettet wird die Erweiterung bereits von `addTarget`: die Bibliothek
|
||||
// legt fuer den Typ "app_extension" von sich aus eine Copy-Phase nach
|
||||
// PlugIns am ersten Ziel an. Hier noch eine zweite anzulegen, hat genau das
|
||||
// erzeugt, wogegen Xcode sich wehrt (2026-09-02, erster Lauf ueberhaupt auf
|
||||
// einem Mac):
|
||||
// error: Unexpected duplicate tasks
|
||||
// note: Target 'App': ValidateEmbeddedBinary .../DataMetric360Share.appex
|
||||
// note: Target 'App': ValidateEmbeddedBinary .../DataMetric360Share.appex
|
||||
// Beide Phasen zeigten auf dieselbe Datei und dasselbe Ziel - die .appex
|
||||
// waere zweimal an denselben Ort kopiert worden.
|
||||
|
||||
writeFileSync(pbxPfad, projekt.writeSync(), "utf8")
|
||||
} catch (fehler) {
|
||||
abbruch(`Das Xcode-Projekt ließ sich nicht bearbeiten: ${fehler.message}`)
|
||||
}
|
||||
|
||||
melden(`Ziel ${ZIEL_NAME} angelegt und eingebettet`)
|
||||
melden(`App-Gruppe ${GRUPPE} auf beiden Zielen`)
|
||||
console.log("")
|
||||
console.log(" Einmalig im Apple-Entwicklerportal nötig, sonst schlägt das Signieren fehl:")
|
||||
console.log(` - App-Gruppe ${GRUPPE} anlegen`)
|
||||
console.log(` - App-ID ${BUNDLE_ID} anlegen und der Gruppe zuordnen`)
|
||||
console.log(` - App-ID app.datametric360 ebenfalls der Gruppe zuordnen`)
|
||||
console.log("")
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Baut die drei App-Symbol-Vorlagen als SVG.
|
||||
*
|
||||
* Gewaehlt wurde Entwurf 3 bei 54 % Fahrzeuggroesse: DataMetric oben, das
|
||||
* CI-Fahrzeug in der Mitte, 360 in Audi-Rot unten, auf Nacht-Grund.
|
||||
*
|
||||
* WARUM DIE SCHRIFTEN EINGEBETTET WERDEN
|
||||
* --------------------------------------
|
||||
* Eine SVG-Datei, die nur `font-family: "Audi Type"` nennt, sieht nur dort
|
||||
* richtig aus, wo die Schrift installiert ist. Auf einem anderen Rechner - und
|
||||
* in jedem Rasterisierer - faellt sie auf eine Ersatzschrift zurueck, und das
|
||||
* faellt erst am fertigen Symbol auf. Die woff2-Dateien stecken deshalb als
|
||||
* data:-URI in der Datei; sie ist damit fuer sich allein vollstaendig.
|
||||
*
|
||||
* DIE DREI AUSFUEHRUNGEN VON iOS 18
|
||||
* ---------------------------------
|
||||
* hell - die Kachel wie entworfen, mit eigenem Grund.
|
||||
* dunkel - ohne Grund. iOS legt seinen eigenen dunklen Verlauf darunter; ein
|
||||
* mitgeliefertes Schwarz wuerde darauf als Kasten sichtbar bleiben.
|
||||
* getoent - ohne Grund und ohne Farbe. iOS faerbt das Bild selbst nach der
|
||||
* Wahl des Nutzers ein und wertet dafuer die Helligkeit aus - das
|
||||
* Rot der 360 wird deshalb zu einem helleren Grau als der Rest,
|
||||
* damit die Zahl auch getoent noch abgesetzt bleibt.
|
||||
*
|
||||
* MASSE
|
||||
* -----
|
||||
* Alles als Anteil der Kantenlaenge, hergeleitet aus dem freigegebenen
|
||||
* Entwurf (Flex-Spalte, Abstand 0,02 - siehe index.html):
|
||||
*
|
||||
* Wort 0,095 | Abstand 0,02 | Fahrzeug 0,54 | Abstand 0,02 | Zahl 0,17
|
||||
* zusammen 0,845, der Rest verteilt sich gleichmaessig oben und unten.
|
||||
*/
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
|
||||
const SCHRIFTEN = path.join(__dirname, "..", "src", "assets", "audi", "schriften")
|
||||
|
||||
/* Das CI-Fahrzeug kommt aus dem Panel selbst - eine zweite Kopie des Pfades
|
||||
waere eine zweite Quelle fuer dieselbe Form. */
|
||||
const PANEL = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"custom_components",
|
||||
"audi_dashboard",
|
||||
"frontend",
|
||||
"audi-dashboard-app.js",
|
||||
)
|
||||
const MARKE = "audi: '<path"
|
||||
const roh = fs.readFileSync(PANEL, "utf8")
|
||||
const von = roh.indexOf(MARKE)
|
||||
if (von < 0) throw new Error("ICONS.audi nicht in audi-dashboard-app.js gefunden")
|
||||
const dVon = roh.indexOf('d="', von) + 3
|
||||
const dBis = roh.indexOf('"', dVon)
|
||||
const AUTO = roh.slice(dVon, dBis)
|
||||
if (!AUTO.startsWith("M")) throw new Error("Pfad sieht nicht wie erwartet aus: " + AUTO.slice(0, 30))
|
||||
|
||||
function schriftDaten(datei) {
|
||||
return fs.readFileSync(path.join(SCHRIFTEN, datei)).toString("base64")
|
||||
}
|
||||
|
||||
const TYPE = schriftDaten("audi-type-400.woff2")
|
||||
const WIDE = schriftDaten("audi-type-wide-300.woff2")
|
||||
|
||||
/* Anteile der Kantenlaenge. */
|
||||
const A = {
|
||||
wort: 0.095,
|
||||
fahrzeug: 0.54,
|
||||
zahl: 0.17,
|
||||
abstand: 0.02,
|
||||
}
|
||||
|
||||
const S = 1024
|
||||
const gesamt = (A.wort + A.abstand + A.fahrzeug + A.abstand + A.zahl) * S
|
||||
const oben = (S - gesamt) / 2
|
||||
|
||||
const wortMitte = oben + (A.wort * S) / 2
|
||||
const autoOben = oben + (A.wort + A.abstand) * S
|
||||
const autoKante = A.fahrzeug * S
|
||||
const zahlMitte = autoOben + autoKante + A.abstand * S + (A.zahl * S) / 2
|
||||
|
||||
/** Das CI-Fahrzeug aus dem 24er-Raster auf seine Kachelgroesse gebracht. */
|
||||
function fahrzeug(farbe) {
|
||||
const faktor = autoKante / 24
|
||||
const links = (S - autoKante) / 2
|
||||
return `<g transform="translate(${links.toFixed(2)} ${autoOben.toFixed(2)}) scale(${faktor.toFixed(5)})"><path d="${AUTO}" fill="${farbe}"/></g>`
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} o
|
||||
* @param {string|null} o.grund Hintergrund, oder null fuer keinen
|
||||
* @param {string} o.vorn Wort und Fahrzeug
|
||||
* @param {string} o.akzent die 360
|
||||
*/
|
||||
function symbol({ grund, vorn, akzent }) {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${S}" height="${S}" viewBox="0 0 ${S} ${S}">
|
||||
<style>
|
||||
@font-face { font-family: "Audi Type"; font-weight: 400; src: url(data:font/woff2;base64,${TYPE}) format("woff2"); }
|
||||
@font-face { font-family: "Audi Type Wide"; font-weight: 300; src: url(data:font/woff2;base64,${WIDE}) format("woff2"); }
|
||||
.wort { font-family: "Audi Type"; font-weight: 400; letter-spacing: -0.01em; }
|
||||
.zahl { font-family: "Audi Type Wide"; font-weight: 300; letter-spacing: -0.03em; }
|
||||
</style>
|
||||
${grund ? ` <rect width="${S}" height="${S}" fill="${grund}"/>\n` : ""}\
|
||||
<text class="wort" x="${S / 2}" y="${wortMitte.toFixed(2)}" font-size="${(A.wort * S).toFixed(2)}" fill="${vorn}" text-anchor="middle" dominant-baseline="central">DataMetric</text>
|
||||
${fahrzeug(vorn)}
|
||||
<text class="zahl" x="${S / 2}" y="${zahlMitte.toFixed(2)}" font-size="${(A.zahl * S).toFixed(2)}" fill="${akzent}" text-anchor="middle" dominant-baseline="central">360</text>
|
||||
</svg>
|
||||
`
|
||||
}
|
||||
|
||||
const AUSFUEHRUNGEN = {
|
||||
/* Nacht-Canvas und Audi-Rot aus den Tokens des Projekts. */
|
||||
hell: { grund: "#0C1014", vorn: "#FFFFFF", akzent: "#F50537" },
|
||||
dunkel: { grund: null, vorn: "#FFFFFF", akzent: "#F50537" },
|
||||
/* Getoent wertet iOS die Helligkeit aus: Weiss fuer Wort und Fahrzeug, ein
|
||||
helles Grau fuer die Zahl - so bleibt sie abgesetzt, ohne eine Farbe zu
|
||||
behaupten, die es getoent nicht gibt. */
|
||||
getoent: { grund: null, vorn: "#FFFFFF", akzent: "#B4B4B8" },
|
||||
}
|
||||
|
||||
const ZIEL = path.join(__dirname, "..", "native", "logo")
|
||||
fs.mkdirSync(ZIEL, { recursive: true })
|
||||
|
||||
for (const [name, o] of Object.entries(AUSFUEHRUNGEN)) {
|
||||
const datei = path.join(ZIEL, `AppIcon-${name}.svg`)
|
||||
fs.writeFileSync(datei, symbol(o), "utf8")
|
||||
console.log(`${path.basename(datei)} ${(fs.statSync(datei).size / 1024).toFixed(0)} KB`)
|
||||
}
|
||||
|
||||
console.log("")
|
||||
console.log("Masse bei 1024:")
|
||||
console.log(` Wort Mitte y=${wortMitte.toFixed(1)} Groesse ${(A.wort * S).toFixed(1)}`)
|
||||
console.log(` Fahrzeug oben y=${autoOben.toFixed(1)} Kante ${autoKante.toFixed(1)}`)
|
||||
console.log(` Zahl Mitte y=${zahlMitte.toFixed(1)} Groesse ${(A.zahl * S).toFixed(1)}`)
|
||||
@@ -0,0 +1,153 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Packt die gebaute Companion-App als OTA-Bündel in die Integration.
|
||||
|
||||
.BESCHREIBUNG
|
||||
Erzeugt aus companion-app/dist zwei Dateien:
|
||||
|
||||
custom_components/audi_dashboard/frontend/app/bundle.zip
|
||||
custom_components/audi_dashboard/frontend/app/bundle.json
|
||||
|
||||
Die iOS-App lädt die Zip zur Laufzeit nach und tauscht ihre Oberfläche aus,
|
||||
ohne dass Xcode gebraucht wird (siehe companion-app/src/daten/ota.ts).
|
||||
|
||||
WARUM DAS BÜNDEL IN DER INTEGRATION LIEGT
|
||||
-----------------------------------------
|
||||
Weil es dann von selbst dorthin kommt, wo die App es sucht. install.ps1
|
||||
(der einzige Installationsweg - HACS scheidet aus, siehe VERSIONIERUNG.md)
|
||||
kopiert den Ordner custom_components/audi_dashboard/ vollständig - das
|
||||
Bündel reist bei jedem Lauf mit, ohne zweiten Auslieferungsweg. Ein
|
||||
Ablageort unter /config/www/ hätte einen eigenen Weg dorthin gebraucht, den
|
||||
man beim Ausliefern hätte vergessen können.
|
||||
|
||||
Der Nebeneffekt ist der eigentliche Gewinn: Bündel und Integration können
|
||||
gar nicht auseinanderlaufen, weil sie dieselbe Datei sind.
|
||||
|
||||
DREI DINGE, DIE HIER BEWUSST SO SIND
|
||||
------------------------------------
|
||||
1. **Sourcemaps fliegen raus.** Sie machen rund zwei Drittel von dist/ aus
|
||||
(1,4 von 2,0 MB) und werden auf dem Gerät nie gebraucht. Sie mitzuladen
|
||||
hieße, bei jedem Update das Dreifache über die Leitung zu schicken.
|
||||
|
||||
2. **Die Einträge werden von Hand geschrieben, nicht von einer Bequem-API.**
|
||||
Die Zip-Spezifikation verlangt "/" als Trennzeichen. Windows PowerShell
|
||||
5.1 läuft auf dem .NET Framework, und dort schreiben SOWOHL
|
||||
Compress-Archive ALS AUCH [IO.Compression.ZipFile]::CreateFromDirectory
|
||||
Backslashes in die Einträge von Unterordnern (in .NET Core behoben, hier
|
||||
aber nicht verfügbar). Nachgemessen: die erste Fassung dieses Skripts
|
||||
erzeugte "assets\index-*.js" statt "assets/index-*.js" - iOS hätte daraus
|
||||
Dateien mit Backslash im Namen gemacht statt einen Ordner, und die App
|
||||
wäre mit fehlenden Assets gestartet.
|
||||
|
||||
Deshalb ZipFile::Open plus CreateEntryFromFile mit selbst gebautem
|
||||
Eintragsnamen: dann steht genau das im Archiv, was hier steht. Der Inhalt
|
||||
liegt ohne umschließenden Ordner in der Wurzel, index.html also direkt
|
||||
dort - genau das erwartet das Plugin; fehlt es, wirft set() "no index.html
|
||||
file inside the bundle folder".
|
||||
|
||||
3. **SHA-256 über die Zip, klein geschrieben.** Das Plugin rechnet auf dem
|
||||
Gerät denselben Hash über die heruntergeladene Datei (calcChecksum in
|
||||
CapgoUpdater.swift) und verwirft das Bündel bei Abweichung. Ein
|
||||
abgeschnittener Download oder eine halb gespiegelte Datei fällt damit
|
||||
auf, bevor die App sie startet.
|
||||
|
||||
.PARAMETER Bauen
|
||||
Vorher "npm run build" ausführen. Ohne das wird der Inhalt von dist/
|
||||
genommen, wie er gerade daliegt.
|
||||
|
||||
.BEISPIEL
|
||||
.\ota-paket.ps1 -Bauen
|
||||
npm run ota
|
||||
#>
|
||||
param(
|
||||
[switch]$Bauen
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Add-Type -AssemblyName System.IO.Compression
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
|
||||
$hier = $PSScriptRoot
|
||||
$app = Split-Path $hier -Parent
|
||||
$projekt = Split-Path $app -Parent
|
||||
$dist = Join-Path $app "dist"
|
||||
$manifest = Join-Path $projekt "custom_components\audi_dashboard\manifest.json"
|
||||
$ziel = Join-Path $projekt "custom_components\audi_dashboard\frontend\app"
|
||||
|
||||
function Gut($t) { Write-Host " [ok] $t" -ForegroundColor Green }
|
||||
function Info($t) { Write-Host " [info] $t" -ForegroundColor DarkGray }
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " OTA-Bündel bauen" -ForegroundColor White
|
||||
Write-Host " ================" -ForegroundColor White
|
||||
|
||||
if ($Bauen) {
|
||||
Info "npm run build ..."
|
||||
Push-Location $app
|
||||
try { npm run build; if ($LASTEXITCODE -ne 0) { throw "npm run build fehlgeschlagen" } }
|
||||
finally { Pop-Location }
|
||||
}
|
||||
|
||||
if (-not (Test-Path (Join-Path $dist "index.html"))) {
|
||||
throw "$dist\index.html fehlt - erst 'npm run build' laufen lassen (oder -Bauen benutzen)."
|
||||
}
|
||||
|
||||
$version = (Get-Content $manifest -Raw | ConvertFrom-Json).version
|
||||
if (-not $version) { throw "Kein 'version'-Feld in $manifest" }
|
||||
Gut "Version: $version"
|
||||
|
||||
# In einen Zwischenordner spiegeln, damit die Sourcemaps nicht mitwandern -
|
||||
# dist/ selbst bleibt unangetastet (die Maps sind beim Entwickeln nützlich).
|
||||
$tmp = Join-Path ([IO.Path]::GetTempPath()) ("dm360-ota-" + [Guid]::NewGuid().ToString("N"))
|
||||
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
|
||||
try {
|
||||
Copy-Item "$dist\*" $tmp -Recurse -Force
|
||||
$maps = Get-ChildItem $tmp -Recurse -File -Filter "*.map"
|
||||
$maps | ForEach-Object { Remove-Item $_.FullName -Force }
|
||||
Info "$($maps.Count) Sourcemap(s) ausgelassen"
|
||||
|
||||
if (-not (Test-Path (Join-Path $tmp "index.html"))) {
|
||||
throw "index.html liegt nicht in der Wurzel des Bündels - das Plugin würde es ablehnen."
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $ziel | Out-Null
|
||||
$zip = Join-Path $ziel "bundle.zip"
|
||||
if (Test-Path $zip) { Remove-Item $zip -Force }
|
||||
|
||||
# Eintragsnamen selbst bauen (siehe Punkt 2 im Kopfkommentar): relativer
|
||||
# Pfad ab dem Zwischenordner, Backslashes durch "/" ersetzt.
|
||||
$archiv = [IO.Compression.ZipFile]::Open($zip, [System.IO.Compression.ZipArchiveMode]::Create)
|
||||
try {
|
||||
foreach ($datei in Get-ChildItem $tmp -Recurse -File | Sort-Object FullName) {
|
||||
$name = $datei.FullName.Substring($tmp.Length + 1).Replace("\", "/")
|
||||
[void][IO.Compression.ZipFileExtensions]::CreateEntryFromFile(
|
||||
$archiv, $datei.FullName, $name, [IO.Compression.CompressionLevel]::Optimal)
|
||||
}
|
||||
}
|
||||
finally { $archiv.Dispose() }
|
||||
|
||||
$hash = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLower()
|
||||
$groesse = (Get-Item $zip).Length
|
||||
Gut ("bundle.zip {0:N0} Bytes" -f $groesse)
|
||||
Gut "sha256 $hash"
|
||||
|
||||
# Kleingeschriebener Hex-Hash, weil das Plugin auf dem Geraet ebenfalls
|
||||
# kleingeschrieben vergleicht.
|
||||
$daten = [ordered]@{
|
||||
version = $version
|
||||
sha256 = $hash
|
||||
bytes = $groesse
|
||||
gebaut = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||
}
|
||||
$json = ($daten | ConvertTo-Json -Compress)
|
||||
[IO.File]::WriteAllText((Join-Path $ziel "bundle.json"), $json, (New-Object Text.UTF8Encoding($false)))
|
||||
Gut "bundle.json geschrieben"
|
||||
}
|
||||
finally {
|
||||
Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " Fertig. Das Bündel wird mit der Integration ausgeliefert -" -ForegroundColor Green
|
||||
Write-Host " homeassistant\installationspaket\Installieren.cmd nimmt es automatisch mit."
|
||||
Write-Host ""
|
||||
@@ -14,7 +14,7 @@ import { readFileSync } from "node:fs"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { dirname, resolve } from "node:path"
|
||||
|
||||
import { ENTITAETEN } from "../src/api/types.ts"
|
||||
import { DIENST_DOMAIN, 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"
|
||||
@@ -104,10 +104,19 @@ try {
|
||||
"tankprozent" in status && "gesichert" in status && "sicherheitscheck" in status,
|
||||
Object.keys(status).slice(0, 6).join(", "),
|
||||
)
|
||||
/* Wie viele Punkte es sind, hängt an der Sensor-Zuordnung der Instanz: vier
|
||||
Türen, vier Fenster, Heckklappe, Motorhaube — unbelegte Rollen liefern gar
|
||||
keinen Eintrag. Eine feste Zahl zu erwarten hieße, die Zuordnung der
|
||||
Testinstanz zu prüfen statt das Backend; genau daran scheiterte diese
|
||||
Prüfung seit dem Entfernen der Türschloss-Erkennung (16 → 10), ohne dass
|
||||
irgendetwas kaputt gewesen wäre. Geprüft wird deshalb die vereinbarte
|
||||
Form: label ist Text, ok ist true/false/null (null = Sensor unbekannt). */
|
||||
const punkte = (status["sicherheitscheck"] ?? []) as { label?: unknown; ok?: unknown }[]
|
||||
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`,
|
||||
"Sicherheitscheck liefert Punkte in der vereinbarten Form",
|
||||
Array.isArray(punkte) &&
|
||||
punkte.every((p) => typeof p.label === "string" && (p.ok === null || typeof p.ok === "boolean")),
|
||||
`${punkte.length} Punkte`,
|
||||
)
|
||||
} catch (e) {
|
||||
pruefe("Fahrzeugstatus lesen", false, e instanceof Error ? e.message : String(e))
|
||||
@@ -128,17 +137,17 @@ for (const [name, id] of [
|
||||
/* -------------------------------------------------------- Dienstaufruf */
|
||||
|
||||
try {
|
||||
await rest.dienstAufrufen("pyscript", "audi_dashboard_jetzt_aktualisieren")
|
||||
pruefe("Dienstaufruf audi_dashboard_jetzt_aktualisieren", true)
|
||||
await rest.dienstAufrufen(DIENST_DOMAIN, "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))
|
||||
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")
|
||||
await warteschlange.einreihen(DIENST_DOMAIN, "jetzt_aktualisieren", {}, "Rauchtest")
|
||||
|
||||
/* einreihen() stößt die Abarbeitung selbst an, ohne sie abzuwarten - ein
|
||||
sofortiger eigener abarbeiten()-Aufruf liefe deshalb ins Leere (der Lauf
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
|
||||
import { bilderVorladen } from "./screens/bilder"
|
||||
|
||||
import { App as CapacitorApp } from "@capacitor/app"
|
||||
|
||||
import { DataMetricApi, zugangLesen } from "./api"
|
||||
import { DatenAnbieter, useDaten } from "./daten/DatenKontext"
|
||||
import { geteiltenBelegLesen, geteiltenBelegSetzen } from "./daten/geteilterBeleg"
|
||||
import { startklarMelden } from "./daten/ota"
|
||||
import { Shell } from "./Shell"
|
||||
import type { Route, SeitenName } from "./navigation"
|
||||
import { useTabBeschriftung, useTheme } from "./theme"
|
||||
import { Bestaetigungsblatt } from "./screens/bestaetigung"
|
||||
import { Einrichtung } from "./screens/Einrichtung"
|
||||
import { Hinweisleiste } from "./screens/Hinweisleiste"
|
||||
import { Versionshinweis } from "./screens/Versionshinweis"
|
||||
import { SeiteFuer } from "./screens/register"
|
||||
import { zugangMerken } from "./screens/zugang"
|
||||
|
||||
@@ -38,6 +46,10 @@ export function App() {
|
||||
|
||||
return (
|
||||
<div className="ads-root dm-wurzel" data-theme={theme}>
|
||||
{/* Einmal fuer die ganze App: die Rueckfrage vor loeschenden Aktionen
|
||||
(siehe screens/bestaetigung.tsx). Rendert nichts, solange nichts
|
||||
gefragt ist. */}
|
||||
<Bestaetigungsblatt />
|
||||
{start === "pruefen" && <div className="dm-laden">Einen Moment …</div>}
|
||||
{start === "einrichten" && <Einrichtung fertig={() => setzeStart("bereit")} />}
|
||||
{start === "bereit" && (
|
||||
@@ -65,13 +77,76 @@ function AngemeldeteApp({ beiAbmeldung }: { beiAbmeldung: () => void }) {
|
||||
if (daten.verbindung === "nicht_angemeldet") beiAbmeldung()
|
||||
}, [daten.verbindung, beiAbmeldung])
|
||||
|
||||
// Meldet dem OTA-Plugin, dass diese Fassung hochgekommen ist (siehe
|
||||
// daten/ota.ts). Bewusst hier und nicht in main.tsx: erst wenn diese
|
||||
// Komponente rendert, hat React tatsächlich etwas auf den Schirm gebracht.
|
||||
// Ein Absturz vor diesem Punkt bleibt unbestätigt, und das Plugin rollt
|
||||
// nach appReadyTimeout von selbst auf das vorherige Bündel zurück.
|
||||
useEffect(() => {
|
||||
void startklarMelden()
|
||||
}, [])
|
||||
|
||||
/* Fahrzeugfotos einmal vorab holen und dekodieren, sobald Daten da sind -
|
||||
siehe bilderVorladen(). Damit erscheint beim Umschalten der Seiten sofort
|
||||
das Bild statt kurz des Platzhalters. */
|
||||
useEffect(() => {
|
||||
if (daten.verbindung === "verbunden") bilderVorladen()
|
||||
}, [daten.verbindung])
|
||||
|
||||
/* Belege aus dem Teilen-Blatt.
|
||||
|
||||
Die Erweiterung legt das PDF ab und oeffnet die App - beim ersten Start
|
||||
kommt die App dadurch frisch hoch, bei einer schon laufenden App nur
|
||||
nach vorn. Deshalb beide Faelle: einmal beim Aufbau, und danach bei
|
||||
jeder Rueckkehr in den Vordergrund.
|
||||
|
||||
Der Beleg wandert in den geteilten Speicher und die App springt auf
|
||||
Tanken - dort nimmt ihn Tanken.tsx auf und laedt ihn hoch, ueber
|
||||
genau denselben Weg wie eine von Hand gewaehlte Datei. */
|
||||
useEffect(() => {
|
||||
let abgemeldet = false
|
||||
|
||||
/* Der Beleg kommt im Aufruf mit: die Teilen-Erweiterung schreibt ihn in
|
||||
den Container der App-Gruppe und öffnet die App mit dem Pfad
|
||||
(ShareViewController.swift). Zwei Wege hierher, weil iOS beide benutzt:
|
||||
war die App zu, steht der Aufruf in getLaunchUrl(); lief sie schon,
|
||||
kommt er als appUrlOpen.
|
||||
|
||||
Bis zum 02.09.2026 stand hier "resume" plus ein Griff in die
|
||||
UserDefaults - der konnte nie etwas finden, die Begründung steht in
|
||||
geteilterBeleg.ts. */
|
||||
const holen = async (url: string | null | undefined) => {
|
||||
const datei = await geteiltenBelegLesen(url)
|
||||
if (abgemeldet || !datei) return
|
||||
geteiltenBelegSetzen(datei)
|
||||
geheZu("fuel")
|
||||
}
|
||||
|
||||
void CapacitorApp.getLaunchUrl().then((start) => holen(start?.url))
|
||||
|
||||
const horcher = CapacitorApp.addListener("appUrlOpen", (ereignis) => {
|
||||
void holen(ereignis.url)
|
||||
})
|
||||
return () => {
|
||||
abgemeldet = true
|
||||
void horcher.then((h) => h.remove())
|
||||
}
|
||||
}, [geheZu])
|
||||
|
||||
return (
|
||||
<Shell
|
||||
route={route}
|
||||
geheZu={geheZu}
|
||||
tabBeschriftung={tabBeschriftung}
|
||||
statusStand={daten.statusStand}
|
||||
// Ziehen zum Aktualisieren gibt es im Panel nur auf der Übersicht.
|
||||
beiAktualisieren={route.name === "home" ? daten.jetztAktualisieren : undefined}
|
||||
hinweis={
|
||||
<Hinweisleiste verbindung={daten.verbindung} warteschlange={daten.warteschlange} />
|
||||
<Hinweisleiste
|
||||
verbindung={daten.verbindung}
|
||||
warteschlange={daten.warteschlange}
|
||||
versionsstand={daten.versionsstand}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SeiteFuer
|
||||
@@ -81,6 +156,13 @@ function AngemeldeteApp({ beiAbmeldung }: { beiAbmeldung: () => void }) {
|
||||
setzeTabBeschriftung={setzeTabBeschriftung}
|
||||
beiAbmeldung={beiAbmeldung}
|
||||
/>
|
||||
{/* Erscheint von selbst, sobald der erste Datenabruf eine neuere
|
||||
Fassung meldet - siehe screens/Versionshinweis.tsx. */}
|
||||
<Versionshinweis
|
||||
versionsstand={daten.versionsstand}
|
||||
serverVersion={daten.serverVersion}
|
||||
otaBuendel={daten.otaBuendel}
|
||||
/>
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Auffangnetz für Abstürze irgendwo im Baum.
|
||||
*
|
||||
* Ohne diese Grenze unmountet React die gesamte App bei jedem unbehandelten
|
||||
* Fehler beim Rendern - #wurzel bleibt leer, und übrig bleibt nur der
|
||||
* Seitenhintergrund (im Nacht-Theme praktisch schwarz). Genau das wurde als
|
||||
* "schwarze Seite nach Update prüfen" gemeldet: kein hängender Ladezustand,
|
||||
* sondern ein stiller Absturz, dessen eigentliche Fehlermeldung nirgends
|
||||
* ankam. Ein "zurück und wieder rein" half nur, weil das erneute Öffnen den
|
||||
* Baum komplett neu aufbaut und der auslösende Zustand dabei verschwindet.
|
||||
*
|
||||
* Diese Grenze behebt nicht die eigentliche Ursache - sie sorgt nur dafür,
|
||||
* dass ein Absturz ab jetzt sichtbar ist (Fehlertext, Neu-laden-Knopf) statt
|
||||
* unsichtbar zu bleiben, und dass der Fehlertext in der Konsole landet, um
|
||||
* die eigentliche Ursache beim nächsten Mal fassen zu können.
|
||||
*/
|
||||
|
||||
import { Component, type ReactNode } from "react"
|
||||
|
||||
import { ActionButton } from "@audi-dash/ui"
|
||||
|
||||
export class ErrorGrenze extends Component<
|
||||
{ children: ReactNode },
|
||||
{ fehler: Error | null }
|
||||
> {
|
||||
state: { fehler: Error | null } = { fehler: null }
|
||||
|
||||
static getDerivedStateFromError(fehler: Error) {
|
||||
return { fehler }
|
||||
}
|
||||
|
||||
componentDidCatch(fehler: Error, info: { componentStack?: string | null }) {
|
||||
console.error("ErrorGrenze: unbehandelter Fehler beim Rendern", fehler, info.componentStack)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.fehler) return this.props.children
|
||||
return (
|
||||
<div className="dm-leer" style={{ minHeight: "100vh", justifyContent: "center" }}>
|
||||
<span className="dm-leer__titel">Etwas ist schiefgelaufen</span>
|
||||
<p className="dm-leer__text">
|
||||
Die Seite konnte nicht dargestellt werden. Ein Neuladen behebt das in der Regel.
|
||||
</p>
|
||||
<p className="dm-leer__text" style={{ fontFamily: "monospace", fontSize: "12px" }}>
|
||||
{this.state.fehler.message}
|
||||
</p>
|
||||
<ActionButton onClick={() => window.location.reload()}>Neu laden</ActionButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@ describe("Shell", () => {
|
||||
expect(bereichVon("trip")).toBe("trips")
|
||||
})
|
||||
|
||||
it("erreicht die Einstellungen über das Zahnrad, nicht über die Tab-Leiste", () => {
|
||||
it("erreicht die Einstellungen über die Ringe, nicht über die Tab-Leiste", () => {
|
||||
const geheZu = rahmen("home")
|
||||
fireEvent.click(screen.getByLabelText("Einstellungen"))
|
||||
expect(geheZu).toHaveBeenCalledWith("einst")
|
||||
|
||||
@@ -7,21 +7,29 @@
|
||||
* liegt bei 1000px.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import type {
|
||||
MutableRefObject,
|
||||
PointerEvent as ReactPointerEvent,
|
||||
ReactNode,
|
||||
RefObject,
|
||||
} from "react"
|
||||
|
||||
import { TabBar } from "@audi-dash/ui"
|
||||
|
||||
import { standAlter } from "./format"
|
||||
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 {
|
||||
SymbolAktualisieren,
|
||||
SymbolAudi,
|
||||
SymbolFahrten,
|
||||
SymbolStatistik,
|
||||
SymbolTanken,
|
||||
SymbolUebersicht,
|
||||
SymbolZahnrad,
|
||||
SymbolZahnradVoll,
|
||||
SymbolZurueck,
|
||||
} from "./symbole"
|
||||
|
||||
@@ -31,10 +39,206 @@ export interface ShellProps {
|
||||
tabBeschriftung: boolean
|
||||
/** Wird über der Kopfzeile eingeblendet (Offline-Hinweis, Warteschlange). */
|
||||
hinweis?: ReactNode | undefined
|
||||
/** Zeitpunkt des letzten Datenstands — steht als „vor 3 Std." unter dem Titel. */
|
||||
statusStand?: string | null | undefined
|
||||
/** Neuabfrage beim Fahrzeug, ausgelöst durch Ziehen zum Aktualisieren. */
|
||||
beiAktualisieren?: (() => Promise<void>) | undefined
|
||||
/** Heißt `children`, weil JSX-Verschachtelung genau diesen Namen erwartet. */
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/** Ziehen zum Aktualisieren, 1:1 nach der ptr-Logik des Panels. */
|
||||
const PTR_SCHWELLE = 56
|
||||
|
||||
/*
|
||||
* Vom linken Rand nach rechts wischen = zurück.
|
||||
*
|
||||
* Warum von Hand: die App ist EINE WebView mit einer flachen Route (siehe
|
||||
* navigation.ts), kein Verlaufsstapel. iOS' eigene Zurück-Geste hängt am
|
||||
* Navigationsstapel eines UINavigationControllers - den gibt es hier nicht,
|
||||
* also gibt es die Geste auch nicht geschenkt. Bis zum 02.09.2026 war der
|
||||
* Pfeil oben links der einzige Weg zurück (gemeldet vom Eigentümer).
|
||||
*
|
||||
* RAND: die Geste muss am linken Rand beginnen. Das ist keine Nachahmung um
|
||||
* ihrer selbst willen, sondern die einzige Art, sie von allem anderen
|
||||
* Waagerechten sauber zu trennen - dem Wischen an einer Listenzeile
|
||||
* (WischLoeschen), dem seitlichen Schieben in der Fotogalerie.
|
||||
*
|
||||
* SCHWELLE: erst ab dieser Strecke wird zurückgegangen. Darunter federt die
|
||||
* Seite zurück - eine angefangene Geste ist damit widerrufbar, indem man den
|
||||
* Finger zurückführt, genau wie bei iOS.
|
||||
*/
|
||||
/* Dieselben Zahlen wie randwischenVerdrahten() im Panel (26px Rand, 62px
|
||||
Auslösung) - die Geste soll sich in beiden Oberflächen gleich anfühlen. */
|
||||
const ZURUECK_RAND = 26
|
||||
const ZURUECK_SCHWELLE = 62
|
||||
const ZURUECK_WEG = 140
|
||||
|
||||
function useWischZurueck(
|
||||
inhalt: RefObject<HTMLElement | null>,
|
||||
zurueck: (() => void) | undefined,
|
||||
belegt: MutableRefObject<boolean>,
|
||||
) {
|
||||
const [zug, setzeZug] = useState<number | null>(null)
|
||||
const start = useRef<{ x: number; y: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = inhalt.current
|
||||
// Der Aussteiger sitzt IM Effekt, nicht davor - sonst hinge die
|
||||
// Hook-Reihenfolge davon ab, ob es ein Zurück-Ziel gibt.
|
||||
if (!el || !zurueck) return undefined
|
||||
|
||||
const beiStart = (e: TouchEvent) => {
|
||||
if (e.touches.length !== 1) return
|
||||
const t = e.touches[0]!
|
||||
if (t.clientX > ZURUECK_RAND) return
|
||||
start.current = { x: t.clientX, y: t.clientY }
|
||||
}
|
||||
const beiBewegung = (e: TouchEvent) => {
|
||||
const s = start.current
|
||||
if (!s || e.touches.length !== 1) return
|
||||
const t = e.touches[0]!
|
||||
const dx = t.clientX - s.x
|
||||
const dy = t.clientY - s.y
|
||||
if (dx <= 0 || Math.abs(dx) < Math.abs(dy) * 1.5) {
|
||||
// Senkrecht oder rückwärts gemeint: die Geste gehört uns nicht.
|
||||
if (Math.abs(dy) > 10) start.current = null
|
||||
return
|
||||
}
|
||||
// Solange wir ziehen, hält sich das Ziehen-zum-Aktualisieren heraus.
|
||||
belegt.current = true
|
||||
setzeZug(Math.min(ZURUECK_WEG, dx))
|
||||
e.preventDefault()
|
||||
}
|
||||
const beiEnde = () => {
|
||||
const gezogen = zug ?? 0
|
||||
start.current = null
|
||||
belegt.current = false
|
||||
setzeZug(null)
|
||||
if (gezogen >= ZURUECK_SCHWELLE) zurueck()
|
||||
}
|
||||
|
||||
el.addEventListener("touchstart", beiStart, { passive: true })
|
||||
el.addEventListener("touchmove", beiBewegung, { passive: false })
|
||||
el.addEventListener("touchend", beiEnde)
|
||||
el.addEventListener("touchcancel", beiEnde)
|
||||
return () => {
|
||||
el.removeEventListener("touchstart", beiStart)
|
||||
el.removeEventListener("touchmove", beiBewegung)
|
||||
el.removeEventListener("touchend", beiEnde)
|
||||
el.removeEventListener("touchcancel", beiEnde)
|
||||
}
|
||||
})
|
||||
|
||||
return zug
|
||||
}
|
||||
|
||||
function useZiehenAktualisieren(
|
||||
inhalt: RefObject<HTMLElement | null>,
|
||||
beiAktualisieren: (() => Promise<void>) | undefined,
|
||||
belegt: MutableRefObject<boolean>,
|
||||
) {
|
||||
const [zug, setzeZug] = useState(0)
|
||||
const [laedt, setzeLaedt] = useState(false)
|
||||
const start = useRef<number | null>(null)
|
||||
|
||||
const beenden = () => {
|
||||
if (start.current === null || !beiAktualisieren) return
|
||||
start.current = null
|
||||
if (zug >= PTR_SCHWELLE) {
|
||||
setzeLaedt(true)
|
||||
void beiAktualisieren().finally(() => {
|
||||
setzeLaedt(false)
|
||||
setzeZug(0)
|
||||
})
|
||||
} else {
|
||||
setzeZug(0)
|
||||
}
|
||||
}
|
||||
|
||||
const starten = (y: number) => {
|
||||
if (laedt || belegt.current) return
|
||||
if ((inhalt.current?.scrollTop ?? 0) > 0) return
|
||||
start.current = y
|
||||
setzeZug(0)
|
||||
}
|
||||
/* Gibt true zurück, wenn die Geste uns gehört — der Aufrufer muss dann
|
||||
preventDefault() rufen. */
|
||||
const bewegen = (y: number): boolean => {
|
||||
if (start.current === null) return false
|
||||
if (belegt.current) {
|
||||
start.current = null
|
||||
setzeZug(0)
|
||||
return false
|
||||
}
|
||||
const dy = y - start.current
|
||||
if (dy > 0 && (inhalt.current?.scrollTop ?? 0) <= 0) {
|
||||
// Widerstand wie bei iOS: halbe Strecke, gedeckelt bei 90px.
|
||||
setzeZug(Math.min(90, dy * 0.5))
|
||||
return true
|
||||
}
|
||||
start.current = null
|
||||
setzeZug(0)
|
||||
return false
|
||||
}
|
||||
|
||||
/* Auf dem Finger läuft die Geste über Touch-Ereignisse, nicht über Pointer.
|
||||
Grund: preventDefault() auf einem Pointer-Ereignis verhindert laut
|
||||
Spezifikation kein Scrollen — darüber entscheidet allein touch-action. Auf
|
||||
einem echten Gerät übernimmt der Browser die senkrechte Geste deshalb
|
||||
sofort, schickt pointercancel und stellt pointermove ein; der Zug wurde
|
||||
zurückgesetzt, bevor er die Schwelle erreichte. Am Rechner fällt das nie
|
||||
auf, eine Maus kennt keine Scroll-Übernahme. Der Listener muss von Hand
|
||||
und mit passive:false hängen — React registriert touchmove passiv, dort
|
||||
wäre preventDefault() wirkungslos. */
|
||||
useEffect(() => {
|
||||
const el = inhalt.current
|
||||
// Der Aussteiger sitzt IM Effekt, nicht davor: ein vorgezogenes return
|
||||
// wuerde diesen Hook je nach Bildschirmbreite mal aufrufen und mal nicht
|
||||
// und damit die Hook-Reihenfolge brechen.
|
||||
if (!el || !beiAktualisieren) return undefined
|
||||
const beiStart = (e: TouchEvent) => {
|
||||
if (e.touches.length === 1) starten(e.touches[0]!.clientY)
|
||||
}
|
||||
const beiBewegung = (e: TouchEvent) => {
|
||||
if (e.touches.length !== 1) return
|
||||
if (bewegen(e.touches[0]!.clientY)) e.preventDefault()
|
||||
}
|
||||
el.addEventListener("touchstart", beiStart, { passive: true })
|
||||
el.addEventListener("touchmove", beiBewegung, { passive: false })
|
||||
el.addEventListener("touchend", beenden)
|
||||
el.addEventListener("touchcancel", beenden)
|
||||
return () => {
|
||||
el.removeEventListener("touchstart", beiStart)
|
||||
el.removeEventListener("touchmove", beiBewegung)
|
||||
el.removeEventListener("touchend", beenden)
|
||||
el.removeEventListener("touchcancel", beenden)
|
||||
}
|
||||
})
|
||||
|
||||
if (!beiAktualisieren) {
|
||||
return { zug: 0, laedt: false, handler: {} as Record<string, never> }
|
||||
}
|
||||
|
||||
return {
|
||||
zug,
|
||||
laedt,
|
||||
handler: {
|
||||
// Nur Maus/Stift — der Finger läuft über die Touch-Ereignisse oben.
|
||||
onPointerDown: (e: ReactPointerEvent) => {
|
||||
if (!e.isPrimary || e.pointerType === "touch") return
|
||||
starten(e.clientY)
|
||||
},
|
||||
onPointerMove: (e: ReactPointerEvent) => {
|
||||
if (!e.isPrimary || e.pointerType === "touch") return
|
||||
bewegen(e.clientY)
|
||||
},
|
||||
onPointerUp: beenden,
|
||||
onPointerCancel: beenden,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const BEREICHE: { key: Hauptbereich; label: string; icon: ReactNode }[] = [
|
||||
{ key: "home", label: "Übersicht", icon: <SymbolUebersicht /> },
|
||||
{ key: "audi", label: "Mein Audi", icon: <SymbolAudi /> },
|
||||
@@ -43,13 +247,46 @@ const BEREICHE: { key: Hauptbereich; label: string; icon: ReactNode }[] = [
|
||||
{ key: "fuel", label: "Tanken", icon: <SymbolTanken /> },
|
||||
]
|
||||
|
||||
export function Shell({ route, geheZu, tabBeschriftung, hinweis, children }: ShellProps) {
|
||||
export function Shell({
|
||||
route,
|
||||
geheZu,
|
||||
tabBeschriftung,
|
||||
hinweis,
|
||||
statusStand,
|
||||
beiAktualisieren,
|
||||
children,
|
||||
}: ShellProps) {
|
||||
const breit = useBreiterBildschirm()
|
||||
const aktiverBereich = bereichVon(route.name)
|
||||
const zurueckZiel = ZURUECK[route.name]
|
||||
|
||||
const unterseite = !istHauptbereich(route.name)
|
||||
const inhaltRef = useRef<HTMLElement | null>(null)
|
||||
// Eine Geste zur Zeit: läuft der Wisch nach rechts, hält sich das Ziehen
|
||||
// zum Aktualisieren heraus. Ohne das zeigte eine schräge Geste am oberen
|
||||
// Seitenrand beides gleichzeitig.
|
||||
const gesteBelegt = useRef(false)
|
||||
const zurueckZug = useWischZurueck(
|
||||
inhaltRef,
|
||||
zurueckZiel && !breit ? () => geheZu(zurueckZiel) : undefined,
|
||||
gesteBelegt,
|
||||
)
|
||||
const ptr = useZiehenAktualisieren(
|
||||
inhaltRef,
|
||||
breit ? undefined : beiAktualisieren,
|
||||
gesteBelegt,
|
||||
)
|
||||
|
||||
// Der Text tickt mit, wie standAlterTicken() im Panel (Sekundentakt).
|
||||
const [, neuZeichnen] = useState(0)
|
||||
useEffect(() => {
|
||||
if (route.name !== "home") return undefined
|
||||
const uhr = window.setInterval(() => neuZeichnen((n) => n + 1), 1000)
|
||||
return () => window.clearInterval(uhr)
|
||||
}, [route.name])
|
||||
|
||||
const kopf = (
|
||||
<header className="dm-kopf">
|
||||
<header className={unterseite ? "dm-kopf dm-kopf--unterseite" : "dm-kopf"}>
|
||||
{zurueckZiel && !breit ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -60,19 +297,52 @@ export function Shell({ route, geheZu, tabBeschriftung, hinweis, children }: She
|
||||
<SymbolZurueck groesse={22} />
|
||||
</button>
|
||||
) : null}
|
||||
{/* Kein Eyebrow: das Panel blendet seinen (`.eyebrow{display:none}` in
|
||||
audi-dashboard-ios.css) auf Telefonbreite vollständig aus - der feste
|
||||
Text "DataMetric360" stand hier über jedem Bildschirm, im Panel steht
|
||||
dort nichts. */}
|
||||
<div className="dm-kopf__titel">
|
||||
<span className="ads-eyebrow">DataMetric360</span>
|
||||
<div className="ads-title">{TITEL[route.name]}</div>
|
||||
{/* Datenstand nur auf der Übersicht, wie `#sync` im Panel. */}
|
||||
{route.name === "home" && (
|
||||
<div className="dm-stand">
|
||||
<SymbolAktualisieren groesse={13} />
|
||||
<span>{standAlter(statusStand)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="dm-kopf__aktion"
|
||||
aria-label="Einstellungen"
|
||||
aria-current={route.name === "einst" ? "page" : undefined}
|
||||
onClick={() => geheZu("einst")}
|
||||
>
|
||||
<SymbolZahnrad groesse={22} />
|
||||
</button>
|
||||
{/* `kopf` wird nur in der schmalen Spalte gerendert (breit hat seinen
|
||||
eigenen Seitenleisten-Kopf, siehe unten) - dort sind die Audi-Ringe
|
||||
oben rechts der einzige Zugang zu Einstellungen, das Zahnrad
|
||||
entfällt, "damit oben rechts nur ein Element sitzt" (Design-Audit
|
||||
2026-08-13, siehe .rings/.profilbtn in audi-dashboard-ios.css).
|
||||
Nur auf den Tab-Ebenen: auf Unterseiten blendet das Panel die Ringe
|
||||
aus (`#marke`.style.display in render()), dort steht links der
|
||||
Zurück-Pfeil. */}
|
||||
{!unterseite ? (
|
||||
<button
|
||||
type="button"
|
||||
className="dm-kopf__aktion"
|
||||
aria-label="Einstellungen"
|
||||
onClick={() => geheZu("einst")}
|
||||
>
|
||||
<VierRinge hoehe={17} />
|
||||
</button>
|
||||
) : (
|
||||
/* Gegengewicht zum Zurueck-Pfeil.
|
||||
|
||||
Der Titel ist auf Unterseiten mittig gesetzt - aber nur innerhalb
|
||||
seines eigenen Kastens, und der beginnt erst hinter dem
|
||||
Zurueck-Pfeil. Ohne ein gleich breites Element auf der rechten
|
||||
Seite sass die Ueberschrift deshalb um dessen halbe Breite zu
|
||||
weit rechts: live gemessen 17px Versatz auf "Standort"
|
||||
(2026-08-31), und zwar auf jeder Unterseite, nicht nur dort.
|
||||
|
||||
Ein leerer Platzhalter statt einer absoluten Positionierung: so
|
||||
behaelt ein langer Titel weiterhin seinen eigenen Platz und
|
||||
schiebt sich nicht unter die Knoepfe. */
|
||||
<span className="dm-kopf__gegengewicht" aria-hidden="true" />
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
|
||||
@@ -110,7 +380,7 @@ export function Shell({ route, geheZu, tabBeschriftung, hinweis, children }: She
|
||||
}
|
||||
onClick={() => geheZu("einst")}
|
||||
>
|
||||
<SymbolZahnrad />
|
||||
<SymbolZahnradVoll />
|
||||
<span>Einstellungen</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -135,7 +405,39 @@ export function Shell({ route, geheZu, tabBeschriftung, hinweis, children }: She
|
||||
<div className="dm-spalte">
|
||||
{hinweis}
|
||||
{kopf}
|
||||
<main className="dm-scroll dm-inhalt">{children}</main>
|
||||
{/* Ziehen zum Aktualisieren wie im Panel (`.ptr`): das Band wächst
|
||||
während der Geste aus der Höhe 0 heraus. */}
|
||||
<div
|
||||
className={ptr.laedt ? "dm-ptr dm-ptr--dreht" : "dm-ptr"}
|
||||
style={{ height: ptr.laedt ? "56px" : `${ptr.zug}px` }}
|
||||
aria-hidden={ptr.zug === 0 && !ptr.laedt}
|
||||
>
|
||||
<SymbolAktualisieren groesse={14} />
|
||||
<span>
|
||||
{ptr.laedt
|
||||
? "Aktualisieren …"
|
||||
: ptr.zug >= 56
|
||||
? "Loslassen zum Aktualisieren"
|
||||
: "Ziehen zum Aktualisieren"}
|
||||
</span>
|
||||
</div>
|
||||
<main
|
||||
className="dm-scroll dm-inhalt"
|
||||
ref={inhaltRef}
|
||||
{...ptr.handler}
|
||||
/* Die Seite folgt dem Finger, damit die Geste sichtbar widerrufbar
|
||||
bleibt: zurückführen unter die Schwelle, und sie federt zurück.
|
||||
Ohne Zug steht hier kein transform - eine stehende Transformation
|
||||
erzeugt einen eigenen Malkontext und hätte position:fixed im Inhalt
|
||||
an sich gebunden. */
|
||||
style={
|
||||
zurueckZug === null
|
||||
? undefined
|
||||
: { transform: `translateX(${zurueckZug}px)`, transition: "none" }
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
<TabBar
|
||||
items={BEREICHE}
|
||||
activeKey={aktiverBereich}
|
||||
|
||||
@@ -13,8 +13,16 @@ 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";
|
||||
import { DIENST_DOMAIN, ENTITAETEN } from "./types.ts";
|
||||
import type {
|
||||
AppVersionAngabe,
|
||||
CsvImportErgebnis,
|
||||
Fahrt,
|
||||
Fahrzeugstatus,
|
||||
ImportErgebnis,
|
||||
Profil,
|
||||
Tankvorgang,
|
||||
} from "./types.ts";
|
||||
import { HassRest } from "./rest.ts";
|
||||
import { HassLive } from "./live.ts";
|
||||
import { Warteschlange } from "./warteschlange.ts";
|
||||
@@ -31,6 +39,39 @@ export interface TankvorgangFelder {
|
||||
station?: string | null;
|
||||
distanz?: number | null;
|
||||
kraftstoff?: string | null;
|
||||
/** Aus einem vorab gelesenen Beleg (Upload ohne tank_id) - so übergibt sie
|
||||
auch das Panel beim Speichern eines neuen Tankvorgangs mit Beleg. */
|
||||
receipt_key?: string | null;
|
||||
receipt_file?: string | null;
|
||||
}
|
||||
|
||||
/** Eingabefelder für Fahrten - Namen wie beim Backend (fahrterkennung.py:
|
||||
fahrt_manuell_anlegen/fahrt_aktualisieren).
|
||||
Alles außer den Zeitpunkten ist optional: leer bleibt es liegen, bis das
|
||||
Kilometerstand-Screening (§7.2) oder eine spätere Bearbeitung es füllt. */
|
||||
/** Eingabefelder für einen archivierten Reifensatz - Namen wie beim Backend
|
||||
(reifen.py: reifen_archiv_aktualisieren). */
|
||||
export interface ReifenArchivFelder {
|
||||
ersetzt_am?: string;
|
||||
km?: number | null;
|
||||
marke?: string | null;
|
||||
modell?: string | null;
|
||||
dot?: string | null;
|
||||
mass?: string | null;
|
||||
druck_vorne?: string | null;
|
||||
druck_hinten?: string | null;
|
||||
kommentar?: string | null;
|
||||
}
|
||||
|
||||
export interface FahrtFelder {
|
||||
ts_start?: string;
|
||||
ts_end?: string;
|
||||
art?: "privat" | "arbeitsweg";
|
||||
start_ort?: string | null;
|
||||
ziel_ort?: string | null;
|
||||
odo_start?: number | null;
|
||||
odo_end?: number | null;
|
||||
distanz?: number | null;
|
||||
}
|
||||
|
||||
/** Bündelt die drei Bausteine und bildet die Fachvorgänge ab, die das alte
|
||||
@@ -77,8 +118,8 @@ export class DataMetricApi {
|
||||
|
||||
profilSchreiben(profil: Profil): Promise<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
"pyscript",
|
||||
"audi_dashboard_profil_schreiben",
|
||||
DIENST_DOMAIN,
|
||||
"profil_schreiben",
|
||||
{ profil_json: JSON.stringify(profil) },
|
||||
"Fahrzeugdaten speichern",
|
||||
);
|
||||
@@ -86,27 +127,62 @@ export class DataMetricApi {
|
||||
|
||||
belegHochladen(pdfBase64: string, dateiname: string, tankId?: string): Promise<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
"pyscript",
|
||||
"audi_dashboard_beleg_hochladen",
|
||||
DIENST_DOMAIN,
|
||||
"beleg_hochladen",
|
||||
{ pdf_base64: pdfBase64, dateiname, ...(tankId ? { tank_id: tankId } : {}) },
|
||||
`Beleg „${dateiname}" hochladen`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Legt eine Fahrt von Hand an — für Fahrten ohne automatische Erkennung. */
|
||||
fahrtAnlegen(tsStart: string, tsEnde: string, art: "privat" | "arbeitsweg"): Promise<unknown> {
|
||||
/**
|
||||
* Fahrzeugfoto hochladen bzw. ersetzen. Dieselben Dienste, die das Panel
|
||||
* über `data-bildupload` anspricht (`bild_hochladen`/`bild_loeschen` in
|
||||
* services.yaml). Die App konnte Fotos bisher gar nicht ändern — sie
|
||||
* verwies stattdessen auf die Verwaltung in Home Assistant.
|
||||
*/
|
||||
bildHochladen(dateiname: string, datenBase64: string): Promise<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
"pyscript",
|
||||
"audi_dashboard_fahrt_manuell_anlegen",
|
||||
{ ts_start: tsStart, ts_end: tsEnde, art },
|
||||
DIENST_DOMAIN,
|
||||
"bild_hochladen",
|
||||
{ dateiname, daten_base64: datenBase64 },
|
||||
`Foto „${dateiname}" hochladen`,
|
||||
)
|
||||
}
|
||||
|
||||
bildLoeschen(dateiname: string): Promise<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
DIENST_DOMAIN,
|
||||
"bild_loeschen",
|
||||
{ dateiname },
|
||||
`Foto „${dateiname}" löschen`,
|
||||
)
|
||||
}
|
||||
|
||||
/** Legt eine Fahrt von Hand an — für Fahrten ohne automatische Erkennung. */
|
||||
fahrtAnlegen(felder: FahrtFelder): Promise<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
DIENST_DOMAIN,
|
||||
"fahrt_manuell_anlegen",
|
||||
{ ...felder },
|
||||
"Fahrt eintragen",
|
||||
);
|
||||
}
|
||||
|
||||
/** Bearbeitet eine bestehende Fahrt, gleich ob automatisch erkannt oder von
|
||||
Hand angelegt. */
|
||||
fahrtAktualisieren(tripId: string, felder: FahrtFelder): Promise<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
DIENST_DOMAIN,
|
||||
"fahrt_aktualisieren",
|
||||
{ trip_id: tripId, ...felder },
|
||||
"Fahrt ändern",
|
||||
);
|
||||
}
|
||||
|
||||
fahrtLoeschen(tripId: string): Promise<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
"pyscript",
|
||||
"audi_dashboard_fahrt_loeschen",
|
||||
DIENST_DOMAIN,
|
||||
"fahrt_loeschen",
|
||||
{ trip_id: tripId },
|
||||
"Fahrt löschen",
|
||||
);
|
||||
@@ -116,8 +192,8 @@ export class DataMetricApi {
|
||||
Parameternamen — belegverarbeitung.py). */
|
||||
tankvorgangAnlegen(felder: TankvorgangFelder): Promise<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
"pyscript",
|
||||
"audi_dashboard_tankvorgang_manuell",
|
||||
DIENST_DOMAIN,
|
||||
"tankvorgang_manuell",
|
||||
{ ...felder },
|
||||
"Tankvorgang eintragen",
|
||||
);
|
||||
@@ -125,8 +201,8 @@ export class DataMetricApi {
|
||||
|
||||
tankvorgangAktualisieren(tankId: string, felder: TankvorgangFelder): Promise<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
"pyscript",
|
||||
"audi_dashboard_tankvorgang_aktualisieren",
|
||||
DIENST_DOMAIN,
|
||||
"tankvorgang_aktualisieren",
|
||||
{ tank_id: tankId, ...felder },
|
||||
"Tankvorgang ändern",
|
||||
);
|
||||
@@ -134,15 +210,188 @@ export class DataMetricApi {
|
||||
|
||||
tankvorgangLoeschen(tankId: string): Promise<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
"pyscript",
|
||||
"audi_dashboard_tankvorgang_loeschen",
|
||||
DIENST_DOMAIN,
|
||||
"tankvorgang_loeschen",
|
||||
{ tank_id: tankId },
|
||||
"Tankvorgang löschen",
|
||||
);
|
||||
}
|
||||
|
||||
/** Löscht einen Tageseintrag aus der Batteriespannungs-Messwertliste. */
|
||||
batterieverlaufEintragLoeschen(datum: string): Promise<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
DIENST_DOMAIN,
|
||||
"batterieverlauf_loeschen",
|
||||
{ datum },
|
||||
"Messwert löschen",
|
||||
);
|
||||
}
|
||||
|
||||
/** "Neue Räder anlegen": archiviert den aktuellen Stand von `satz` und legt
|
||||
einen frischen (km 0) an. */
|
||||
reifenArchivieren(satz: "sommer" | "winter"): Promise<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
DIENST_DOMAIN,
|
||||
"reifen_archivieren",
|
||||
{ satz },
|
||||
"Neue Räder anlegen",
|
||||
);
|
||||
}
|
||||
|
||||
/** Korrigiert nur den Kilometerzähler von `satz` - bewusst kein
|
||||
profilSpeichern() (siehe reifenzaehler.py): ein eigener Dienst, der
|
||||
referenz_odo_km unangetastet lässt, damit die nächste automatische
|
||||
Fortschreibung ab dem neuen Stand weiterzählt statt ihn zu
|
||||
überschreiben. Deckungsgleich mit data-kmspeichern im Panel. */
|
||||
reifenKmSetzen(satz: "sommer" | "winter", km: number): Promise<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
DIENST_DOMAIN,
|
||||
"reifen_km_setzen",
|
||||
{ satz, km },
|
||||
"Kilometerstand korrigieren",
|
||||
);
|
||||
}
|
||||
|
||||
reifenArchivAktualisieren(id: string, felder: ReifenArchivFelder): Promise<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
DIENST_DOMAIN,
|
||||
"reifen_archiv_aktualisieren",
|
||||
{ id, ...felder },
|
||||
"Archivierten Reifensatz ändern",
|
||||
);
|
||||
}
|
||||
|
||||
reifenArchivLoeschen(id: string): Promise<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
DIENST_DOMAIN,
|
||||
"reifen_archiv_loeschen",
|
||||
{ id },
|
||||
"Archivierten Reifensatz löschen",
|
||||
);
|
||||
}
|
||||
|
||||
/** Stößt eine sofortige Aktualisierung im Backend an (Pull-to-refresh). */
|
||||
jetztAktualisieren(): Promise<unknown> {
|
||||
return this.rest.dienstAufrufen("pyscript", "audi_dashboard_jetzt_aktualisieren");
|
||||
return this.rest.dienstAufrufen(DIENST_DOMAIN, "jetzt_aktualisieren");
|
||||
}
|
||||
|
||||
/** Nutzlast von sensor.audi_dashboard_app_version: welchen Stand das
|
||||
Backend ausliefert (`app`, siehe VERSIONIERUNG.md) und ob ein
|
||||
OTA-Bündel bereitliegt (`buendel`, siehe daten/ota.ts). Beides kommt
|
||||
aus derselben Entität und wird deshalb in einem Aufruf gelesen statt in
|
||||
zweien. `null` bei jedem Fehler — fehlende Entität (älteres Backend),
|
||||
Netzproblem, was auch immer; dann wird nichts verglichen und nichts
|
||||
gemeldet, statt einen Fehlalarm auszulösen. */
|
||||
async appVersionAngabeLesen(): Promise<AppVersionAngabe | null> {
|
||||
try {
|
||||
const zustand = await this.rest.zustandLesen<{ daten?: AppVersionAngabe }>(
|
||||
ENTITAETEN.appVersion,
|
||||
);
|
||||
return zustand.attributes?.daten ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- Selbst-Update der
|
||||
Integration (aktualisierung.py) - Ersatz für install.ps1, das Windows
|
||||
Smart App Control blockiert. Bewusst NICHT über die Warteschlange, wie
|
||||
historieImportieren() oben: beides sind ausdrückliche, sofortige
|
||||
Aktionen, kein Zustand, den man im Funkloch absetzt und später
|
||||
nachgeholt haben will. Ergebnis kommt über
|
||||
appVersionAngabeLesen()/daten.integration_update zurück, nicht über den
|
||||
Rückgabewert dieser Aufrufe. */
|
||||
|
||||
/** Fragt nur nach, ob eine neuere Fassung vorliegt - lädt nichts herunter. */
|
||||
updatePruefen(): Promise<unknown> {
|
||||
return this.rest.dienstAufrufen(DIENST_DOMAIN, "update_pruefen");
|
||||
}
|
||||
|
||||
/** Lädt die neueste Fassung und ersetzt den Integrationsordner. Home
|
||||
Assistant muss danach neu gestartet werden. */
|
||||
updateInstallieren(): Promise<unknown> {
|
||||
return this.rest.dienstAufrufen(DIENST_DOMAIN, "update_installieren");
|
||||
}
|
||||
|
||||
/** Startet Home Assistant komplett neu - der letzte Schritt nach
|
||||
updateInstallieren(), damit die neue Fassung tatsächlich geladen wird
|
||||
(ein reiner Reload reicht dafür nicht, siehe AGENTS.md Abschnitt J).
|
||||
Kein audi_dashboard-Dienst, deshalb der Bereich "homeassistant" statt
|
||||
DIENST_DOMAIN. */
|
||||
homeAssistantNeuStarten(): Promise<unknown> {
|
||||
return this.rest.dienstAufrufen("homeassistant", "restart");
|
||||
}
|
||||
|
||||
/* ------------------------------------------- Import aus dem HA-Verlauf
|
||||
Bewusst NICHT über die Warteschlange, anders als die übrigen
|
||||
schreibenden Vorgänge: der Import ist keine Eingabe, die man im Funkloch
|
||||
absetzt und später ausgeführt haben will. Er dauert lange, sein Ergebnis
|
||||
ist der eigentliche Zweck des Aufrufs, und ein nachträglich aus der
|
||||
Warteschlange abgefeuerter Lauf käme für den Nutzer aus dem Nichts.
|
||||
Ohne Netz gehört er schlicht nicht angeboten. */
|
||||
|
||||
/** Startet den Import. Kehrt zurück, sobald das Backend den Auftrag
|
||||
angenommen hat — nicht, wenn er fertig ist; dafür importStatusLesen(). */
|
||||
/** Beendet die laufende Fahrt sofort, mit vorlaeufigem Ende.
|
||||
|
||||
Wie historieImportieren() bewusst NICHT ueber die Warteschlange: im
|
||||
Funkloch abgesetzt und Stunden spaeter nachgeholt wuerde sie eine Fahrt
|
||||
schliessen, die laengst regulaer zu Ende ist. */
|
||||
fahrtJetztBeenden(): Promise<unknown> {
|
||||
return this.rest.dienstAufrufen(DIENST_DOMAIN, "fahrt_jetzt_beenden", {});
|
||||
}
|
||||
|
||||
/** Traegt einen Dienste-Token ein oder loescht ihn (leerer Text = loeschen).
|
||||
|
||||
Nicht ueber die Warteschlange: ein Token, der Stunden spaeter aus dem
|
||||
Funkloch nachgereicht wird, ueberschriebe womoeglich einen inzwischen
|
||||
eingetragenen neuen. Und der Wert kommt nie zurueck - was gesetzt ist,
|
||||
steht in daten.zugaenge, der Token selbst nirgends. */
|
||||
zugangSetzen(dienst: string, token: string): Promise<unknown> {
|
||||
return this.rest.dienstAufrufen(DIENST_DOMAIN, "zugang_setzen", { dienst, token });
|
||||
}
|
||||
|
||||
/** Liest die Konfiguration des Dongles - und schreibt dabei eine vorgemerkte
|
||||
Aenderung, falls eine offen ist (siehe flespi.py).
|
||||
|
||||
Wie zugangSetzen bewusst NICHT ueber die Warteschlange: der Abruf gelingt
|
||||
nur bei laufender Zuendung, und Stunden spaeter aus dem Funkloch nachgeholt
|
||||
traefe er das Geraet garantiert im Schlaf an. */
|
||||
dongleLesen(): Promise<unknown> {
|
||||
return this.rest.dienstAufrufen(DIENST_DOMAIN, "dongle_lesen", {});
|
||||
}
|
||||
|
||||
historieImportieren(start: string, ende: string): Promise<unknown> {
|
||||
return this.rest.dienstAufrufen(DIENST_DOMAIN, "historie_importieren", {
|
||||
start,
|
||||
ende,
|
||||
});
|
||||
}
|
||||
|
||||
/** Stand des laufenden bzw. letzten Imports: "laeuft" | "fertig" |
|
||||
"fehler", plus Zählwerte im Attribut "daten". */
|
||||
async importStatusLesen(): Promise<{ zustand: string; daten: ImportErgebnis }> {
|
||||
const zustand = await this.rest.zustandLesen<{ daten: ImportErgebnis }>(
|
||||
ENTITAETEN.importStatus,
|
||||
);
|
||||
return { zustand: zustand.state, daten: zustand.attributes?.daten ?? {} };
|
||||
}
|
||||
|
||||
/* --------------------------------------------------- CSV-Import
|
||||
Wie historieImportieren() bewusst NICHT über die Warteschlange: eine
|
||||
bewusste, einmalige Aktion, deren Ergebnis der eigentliche Zweck des
|
||||
Aufrufs ist, kein Formularfeld, das man auch offline abschicken würde.
|
||||
Anders als dort aber synchron im Backend (csv_import.py) - der
|
||||
Dienstaufruf kehrt erst zurück, wenn sensor.audi_dashboard_import_status
|
||||
den neuen Stand schon trägt, keine Wartezeit nötig. */
|
||||
async csvImportieren(
|
||||
art: "fahrten" | "tanken" | "service",
|
||||
inhalt: string,
|
||||
): Promise<CsvImportErgebnis> {
|
||||
await this.rest.dienstAufrufen(DIENST_DOMAIN, "csv_importieren", { art, inhalt });
|
||||
const zustand = await this.rest.zustandLesen<{ daten: CsvImportErgebnis }>(
|
||||
ENTITAETEN.importStatus,
|
||||
);
|
||||
return { art, ...(zustand.attributes?.daten ?? {}) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
hass-Objekt geleistet hat (SPECIFICATION.md §3 "Data flow"):
|
||||
hass.states[id].attributes.daten -> zustandLesen()/datenLesen()
|
||||
hass.callService(...) -> dienstAufrufen()
|
||||
Dieselben Entitäten, dieselben pyscript-Dienste - nur über HTTP statt über
|
||||
ein Objekt, das nur innerhalb des HA-Frontends existiert. */
|
||||
Dieselben Entitäten, dieselben Dienste der Integration - nur über HTTP statt
|
||||
über ein Objekt, das nur innerhalb des HA-Frontends existiert. */
|
||||
|
||||
import type { DatenEntity, HassState } from "./types.ts";
|
||||
import { zugangLesen, type Zugang } from "./umgebung.ts";
|
||||
@@ -36,6 +36,20 @@ export class ApiFehler extends Error {
|
||||
get istNetzproblem(): boolean {
|
||||
return this.status === null;
|
||||
}
|
||||
|
||||
/** Vorübergehend nicht erreichbar: kein Netz, oder ein Server, der
|
||||
gerade hochfährt.
|
||||
|
||||
502/503/504 sind genau das, was ein Neustart von Home Assistant
|
||||
liefert - der Vorschaltserver antwortet schon, Home Assistant selbst
|
||||
noch nicht. Das ist für die Oberfläche dasselbe wie kein Netz: warten
|
||||
und den zwischengespeicherten Stand zeigen, nicht eine Fehlerzeile
|
||||
stehen lassen, die niemand mehr wegbekommt. 500 gehört bewusst NICHT
|
||||
dazu - das ist ein echter Fehler in der Anfrage, der sich durch
|
||||
Warten nicht bessert. */
|
||||
get istVoruebergehend(): boolean {
|
||||
return this.istNetzproblem || this.status === 502 || this.status === 503 || this.status === 504;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AnfrageOptionen {
|
||||
@@ -124,8 +138,8 @@ export class HassRest {
|
||||
return (zustand as DatenEntity<T>).attributes.daten;
|
||||
}
|
||||
|
||||
/** Ruft einen Dienst auf, z. B. dienstAufrufen("pyscript",
|
||||
"audi_dashboard_profil_schreiben", { profil_json }). */
|
||||
/** Ruft einen Dienst auf, z. B. dienstAufrufen("audi_dashboard",
|
||||
"profil_schreiben", { profil_json }). */
|
||||
async dienstAufrufen(
|
||||
bereich: string,
|
||||
dienst: string,
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
Die Fachtypen (Fahrt, Tankvorgang, ...) sind aus SPECIFICATION.md §5
|
||||
"Data files" abgeleitet - also aus dem, was das Backend tatsächlich in
|
||||
fahrten.jsonl / tankvorgaenge.jsonl schreibt, nicht aus Wunschdenken.
|
||||
Felder, die laut §7 im Schema stehen, aber von keinem Codepfad je gefüllt
|
||||
werden (start_lat, avg_speed_kmh, route, ...), sind hier bewusst als
|
||||
optional markiert - sie können null/undefined sein und die Oberfläche darf
|
||||
sich nie darauf verlassen. */
|
||||
Mehrere Felder (start_lat/-lon, end_lat/-lon, route, avg_speed_kmh) sind
|
||||
nur befüllt, wenn ein GPS-Sensor zugeordnet ist (screening.py/
|
||||
historienimport.py) - ohne Zuordnung bleiben sie null. Deshalb hier bewusst
|
||||
optional markiert; die Oberfläche darf sich nie auf ihre Existenz verlassen. */
|
||||
|
||||
/** Roher Zustand einer HA-Entität, wie ihn /api/states liefert. */
|
||||
export interface HassState<A = Record<string, unknown>> {
|
||||
@@ -19,7 +19,7 @@ export interface HassState<A = Record<string, unknown>> {
|
||||
context?: { id: string; parent_id: string | null; user_id: string | null };
|
||||
}
|
||||
|
||||
/** Die pyscript.*-Entitäten transportieren ihre Nutzlast im Attribut "daten". */
|
||||
/** Die Entitäten der Integration transportieren ihre Nutzlast im Attribut "daten". */
|
||||
export interface DatenAttribute<T> {
|
||||
daten: T;
|
||||
[weitere: string]: unknown;
|
||||
@@ -40,7 +40,10 @@ export interface Fahrt {
|
||||
duration_s: number;
|
||||
distance_km: number | null;
|
||||
/** "odometer" ist der einzige Wert, den das Backend je erzeugt (§7.1). */
|
||||
km_quelle: "odometer" | "gps" | null;
|
||||
/* "odometer+gnss": der Kilometerstand des Fahrzeugs als Anker, die
|
||||
Nachkommastelle vom GNSS-Zaehler des Geraets - siehe screening.py.
|
||||
"sensor"/"manuell" kommen aus dem Rueckblick bzw. von Hand. */
|
||||
km_quelle: "odometer" | "odometer+gnss" | "gps" | "sensor" | "manuell" | null;
|
||||
odo_start: number | null;
|
||||
odo_end: number | null;
|
||||
art: Fahrtart;
|
||||
@@ -49,13 +52,29 @@ export interface Fahrt {
|
||||
edited_fields?: string[];
|
||||
/* Ab FMM003 erstmals real befüllbar - bis dahin durchgehend null (§7.2). */
|
||||
avg_speed_kmh?: number | null;
|
||||
vmax_kmh?: number | null;
|
||||
start_lat?: number | null;
|
||||
start_lon?: number | null;
|
||||
end_lat?: number | null;
|
||||
end_lon?: number | null;
|
||||
start_address?: string | null;
|
||||
end_address?: string | null;
|
||||
route?: unknown | null;
|
||||
/* Nur der Ortsname, vom Backend aufgeloest (geokodierung.py). Die Liste
|
||||
hat je Fahrt eine Zeile - zwei volle Anschriften passen dort nicht. */
|
||||
start_stadt?: string | null;
|
||||
end_stadt?: string | null;
|
||||
route?: [number, number][] | null;
|
||||
/** Näherung aus der Literstand-Differenz (TANK_LITER_SENSOR) ÷ Distanz, kein
|
||||
* vom Fahrzeug selbst für diese eine Fahrt gemeldeter Wert - siehe
|
||||
* verbrauch_aus_literstaenden() im Backend. */
|
||||
verbrauch_l_100km?: number | null;
|
||||
/** Das Ende steht nur vorlaeufig fest (Knopf "Fahrt beenden", das Geraet
|
||||
hat sein Zuendungs-Aus noch nicht gemeldet). */
|
||||
ende_vorlaeufig?: boolean;
|
||||
/** Kilometer am Anfang der Fahrt, die das Geraet verschlafen hat. */
|
||||
luecke_km?: number | null;
|
||||
/** Die Zuordnung dieser Luecke ist unsicher - siehe screening.py. */
|
||||
luecke_unsicher?: boolean;
|
||||
pausen?: unknown[];
|
||||
}
|
||||
|
||||
@@ -70,6 +89,9 @@ export interface Tankvorgang {
|
||||
/** Immer aus fuel_total_eur/liters berechnet, nie aus dem Beleg gelesen. */
|
||||
price_per_l: number | null;
|
||||
station_name?: string | null;
|
||||
/** Kopfmarke des Belegs (shell_beleg_parser). Fehlt bei Vorgaengen ohne
|
||||
Beleg und bei Belegen, deren Datei nicht mehr liegt. */
|
||||
station_brand?: string | null;
|
||||
station_id?: string | null;
|
||||
station_address?: string | null;
|
||||
fuel_type?: string | null;
|
||||
@@ -88,14 +110,17 @@ export interface Tankvorgang {
|
||||
edited_fields?: string[];
|
||||
}
|
||||
|
||||
/** Einzelprüfung aus _sicherheitscheck: ok===null heißt "Sensor unbekannt". */
|
||||
/** Einzelprüfung aus _sicherheitscheck: ok===null heißt "Sensor unbekannt".
|
||||
"gruppe" fasst mehrere Punkte zu einer Sammelzeile zusammen (verriegelt /
|
||||
tueren_klappen / fenster_dach / licht), siehe Fahrzeugstatus.tsx. */
|
||||
export interface SicherheitsPunkt {
|
||||
label: string;
|
||||
ok: boolean | null;
|
||||
gruppe: "verriegelt" | "tueren_klappen" | "fenster_dach" | "licht";
|
||||
}
|
||||
|
||||
/** Feldnamen an der laufenden Instanz abgelesen, nicht aus der Spezifikation
|
||||
abgeleitet: frontend_veroeffentlichung.py schreibt "tankprozent",
|
||||
abgeleitet: 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. */
|
||||
@@ -106,6 +131,18 @@ export interface Fahrzeugstatus {
|
||||
batteriespannung?: number | null;
|
||||
gesichert?: boolean | null;
|
||||
sicherheitscheck?: SicherheitsPunkt[];
|
||||
/** Diagnosecodes im Fehlerspeicher. Getrennt vom Sicherheitscheck, weil ein
|
||||
Eintrag nichts darueber sagt, ob der Wagen verschlossen dasteht. */
|
||||
fehlerspeicher?: { anzahl: number | null; ok: boolean | null } | null;
|
||||
/** Zustand des Zündungssensors — ausdrücklich nicht „fährt": es gibt keinen
|
||||
Bewegungssensor, und die Zündung meldet an auch bei Zubehörstellung oder
|
||||
im Stand. `null`/fehlend heißt „kein Zündungssensor zugeordnet", also
|
||||
unbekannt — ebenfalls nicht „steht". */
|
||||
zuendung?: boolean | null;
|
||||
standort_lat?: number | null;
|
||||
standort_lon?: number | null;
|
||||
standort_genauigkeit_m?: number | null;
|
||||
standort_zeit?: string | null;
|
||||
oelwechsel_faellig_ts?: string | null;
|
||||
oelwechsel_faellig_km?: number | null;
|
||||
inspektion_faellig_ts?: string | null;
|
||||
@@ -131,19 +168,196 @@ export interface Profil {
|
||||
[weitere: string]: unknown;
|
||||
}
|
||||
|
||||
/** Ergebnis eines Historien-Imports (custom_components/audi_dashboard/historienimport.py). Die
|
||||
Zählwerte fehlen, solange der Lauf noch nicht fertig ist; `meldung` steht
|
||||
nur im Fehlerfall. */
|
||||
export interface ImportErgebnis {
|
||||
von?: string;
|
||||
bis?: string;
|
||||
/** Frühester Zeitpunkt mit Aufzeichnung im gewählten Fenster — erklärt ein
|
||||
leeres Ergebnis („weiter zurück bewahrt HA nichts mehr auf"). */
|
||||
ab_wann_daten?: string | null;
|
||||
fahrten_angelegt?: number;
|
||||
fahrten_uebersprungen?: number;
|
||||
fahrten_zu_kurz?: number;
|
||||
fahrten_nicht_gefahren?: number;
|
||||
tankvorgaenge_angelegt?: number;
|
||||
tankvorgaenge_uebersprungen?: number;
|
||||
batterie_tage?: number;
|
||||
meldung?: string;
|
||||
}
|
||||
|
||||
/** Ergebnis eines CSV-Imports (custom_components/audi_dashboard/csv_import.py) -
|
||||
dieselbe Entität wie ImportErgebnis (sensor.audi_dashboard_import_status),
|
||||
aber eine andere Kennzahlform: Zeilenabgleich statt Fahrten/Tankvorgänge/
|
||||
Batterie getrennt. */
|
||||
export interface CsvImportErgebnis {
|
||||
art?: string;
|
||||
angelegt?: number;
|
||||
aktualisiert?: number;
|
||||
uebersprungen?: number;
|
||||
}
|
||||
|
||||
/** Das OTA-Bündel, das die Integration mit ausliefert (siehe
|
||||
custom_components/audi_dashboard/koordinator.py `_buendel_lesen()` und
|
||||
companion-app/scripts/ota-paket.ps1). `null`, solange keine
|
||||
bundle.json neben der Integration liegt — ein völlig normaler Zustand,
|
||||
kein Fehler: wer nur das Panel nutzt, braucht kein App-Bündel. */
|
||||
export interface Buendelangabe {
|
||||
version: string;
|
||||
sha256: string;
|
||||
bytes: number;
|
||||
gebaut: string;
|
||||
/** Pfad relativ zur Home-Assistant-Basis-URL, z. B.
|
||||
"/audi_dashboard_static/app/bundle.zip". Von der Integration
|
||||
angehängt, steht nicht in bundle.json selbst. */
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** Ergebnis der letzten "Auf Update prüfen"/"Update installieren"-Aktion
|
||||
(custom_components/audi_dashboard/aktualisierung.py, dienste.py). `null`,
|
||||
solange noch nie geprüft wurde - ein normaler Zustand, kein Fehler.
|
||||
Ersetzt install.ps1 als Update-Weg, weil Windows Smart App Control es
|
||||
zuverlässig blockiert. */
|
||||
export interface IntegrationUpdateAngabe {
|
||||
verfuegbar: boolean;
|
||||
version: string | null;
|
||||
geprueft_am: string;
|
||||
/** true unmittelbar nach einer erfolgreichen Installation. */
|
||||
installiert: boolean;
|
||||
/** Ob nach der Installation wirklich ein Home-Assistant-Neustart nötig ist.
|
||||
|
||||
Der Updater entscheidet das am Datei-Vergleich: betrifft das Update nur
|
||||
`frontend/`, reicht ein Neuladen der Seite - die Oberflächendateien
|
||||
werden direkt von der Platte ausgeliefert. Nur geänderter Python-Code
|
||||
braucht einen Neustart, weil Home Assistant seine Module einmal beim
|
||||
Start importiert.
|
||||
|
||||
Fehlt das Feld (ältere Integration), gilt "Neustart nötig". */
|
||||
neustart_noetig?: boolean;
|
||||
fehler: string | null;
|
||||
}
|
||||
|
||||
/** Ein Dienste-Token dieser Installation (zugaenge.py).
|
||||
|
||||
Der Token selbst ist hier BEWUSST nicht enthalten und kommt auch nie ueber
|
||||
die Leitung: gezeigt wird nur, ob einer gesetzt ist, und seine letzten vier
|
||||
Zeichen zum Wiedererkennen. */
|
||||
export interface ZugangAngabe {
|
||||
dienst: string;
|
||||
titel: string;
|
||||
zweck: string;
|
||||
/** Laeuft die App ohne diesen Token gar nicht? Beide heutigen tun es. */
|
||||
erforderlich: boolean;
|
||||
gesetzt: boolean;
|
||||
rest: string | null;
|
||||
}
|
||||
|
||||
/** Was am Dongle steht und was noch aussteht (flespi.py).
|
||||
|
||||
`aktiv` ist false, solange kein flespi-Token hinterlegt ist - dann gibt es
|
||||
nichts zu zeigen. `stand` traegt seinen eigenen Zeitstempel: eine Angabe von
|
||||
gestern soll nicht wie eine frische aussehen. Was noch zum Geraet unterwegs
|
||||
ist, steht je Wert in `offen` - flespi haelt es vor, wir nicht. */
|
||||
export interface DongleWert {
|
||||
schluessel: string;
|
||||
/** Die Nummer im flespi-Configurator - danach sucht man dort. */
|
||||
nummer: string;
|
||||
titel: string;
|
||||
einheit: string;
|
||||
geraet: unknown;
|
||||
/** Was noch zum Gerät unterwegs ist (flespis `pending`) - null, wenn nichts
|
||||
aussteht. */
|
||||
offen: unknown;
|
||||
/** Unser eigener Wert, sofern es einen zu vergleichen gibt. */
|
||||
unser: number | null;
|
||||
/** Dreiwertig: true weicht ab, false stimmt überein, **null kein
|
||||
Urteil** — der Wert wurde nie vom Gerät bestätigt, oder es ist
|
||||
gerade eine Änderung zu ihm unterwegs. Bis zum 04.09.2026 war das
|
||||
ein Bool, und `false` behauptete Übereinstimmung auch gegen einen
|
||||
tagealten Wert. */
|
||||
abweichung: boolean | null;
|
||||
/** Wann das Gerät diesen Wert zuletzt bestätigt hat (flespis
|
||||
`updated`) — null heißt: nie. */
|
||||
gemeldet_am?: string | null;
|
||||
}
|
||||
|
||||
export interface DongleStand {
|
||||
geraet?: number;
|
||||
gelesen_am: string;
|
||||
anzahl?: number;
|
||||
werte?: DongleWert[];
|
||||
abweichungen?: number;
|
||||
/** Zeilen ohne Urteil: nie bestätigt oder gerade unterwegs. */
|
||||
unbekannt?: number;
|
||||
/** Welche Zwischenspeicher dieser Abruf geleert hat. */
|
||||
geleert?: string[];
|
||||
/** Wie viele der gezeigten Werte noch zum Gerät unterwegs sind. */
|
||||
offen?: number;
|
||||
fehler: string | null;
|
||||
}
|
||||
|
||||
export interface DongleAngabe {
|
||||
aktiv: boolean;
|
||||
stand: DongleStand | null;
|
||||
}
|
||||
|
||||
/** Ein einzelner Wert aus der letzten Meldung des Dongles (geraet.py). */
|
||||
export interface DatensatzWert {
|
||||
name: string;
|
||||
wert: string;
|
||||
einheit: string | null;
|
||||
}
|
||||
|
||||
/** Die letzte Meldung des Dongles, so wie sie in Home Assistant ankam.
|
||||
"angekommen" ist die Ankunftszeit, "meldezeit" die Zeit des Geraets -
|
||||
der Abstand zwischen beiden ist der Puffer-Rueckstand. */
|
||||
export interface LetzterDatensatz {
|
||||
geraet: string;
|
||||
angekommen: string;
|
||||
meldezeit: string | null;
|
||||
werte: DatensatzWert[];
|
||||
gesamt: number;
|
||||
}
|
||||
|
||||
/** Nutzlast von sensor.audi_dashboard_app_version. */
|
||||
export interface AppVersionAngabe {
|
||||
app: string | null;
|
||||
buendel: Buendelangabe | null;
|
||||
integration_update: IntegrationUpdateAngabe | null;
|
||||
zugaenge?: ZugangAngabe[];
|
||||
dongle?: DongleAngabe;
|
||||
letzter_datensatz?: LetzterDatensatz | null;
|
||||
/** Zeitstempel je vorhandenem Fahrzeugfoto - siehe bilder.staende(). */
|
||||
bildstaende?: Record<string, number> | null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- Entitäts-Verzeichnis */
|
||||
|
||||
/** Alle vom Backend veröffentlichten pyscript-Entitäten an einer Stelle.
|
||||
/** Alle vom Backend veröffentlichten Entitäten an einer Stelle.
|
||||
Kein Hardcoding über die App verstreut: wer eine Entität umbenennt,
|
||||
ändert genau diese Tabelle. */
|
||||
ändert genau diese Tabelle.
|
||||
|
||||
Die Namen sind ein Vertrag mit custom_components/audi_dashboard/const.py;
|
||||
das HA-Panel führt dieselbe Tabelle in audi-dashboard-app.js. Bis
|
||||
2026-08-23 hießen sie pyscript.audi_dashboard_*, weil pyscript sie
|
||||
bereitstellte und die Domain besaß. Seit dem Umbau zur eigenen Integration
|
||||
sind es echte Entitäten dieser Integration. */
|
||||
export const ENTITAETEN = {
|
||||
profil: "pyscript.audi_dashboard_profil",
|
||||
fahrten: "pyscript.audi_dashboard_fahrten",
|
||||
tankvorgaenge: "pyscript.audi_dashboard_tankvorgaenge",
|
||||
fahrzeugstatus: "pyscript.audi_dashboard_fahrzeugstatus",
|
||||
batterieverlauf: "pyscript.audi_dashboard_batterieverlauf",
|
||||
belegErgebnis: "pyscript.audi_dashboard_beleg_ergebnis",
|
||||
updateStatus: "pyscript.audi_dashboard_update_status",
|
||||
profil: "sensor.audi_dashboard_profil",
|
||||
fahrten: "sensor.audi_dashboard_fahrten",
|
||||
tankvorgaenge: "sensor.audi_dashboard_tankvorgaenge",
|
||||
fahrzeugstatus: "sensor.audi_dashboard_fahrzeugstatus",
|
||||
batterieverlauf: "sensor.audi_dashboard_batterieverlauf",
|
||||
belegErgebnis: "sensor.audi_dashboard_beleg_ergebnis",
|
||||
importStatus: "sensor.audi_dashboard_import_status",
|
||||
appVersion: "sensor.audi_dashboard_app_version",
|
||||
} as const;
|
||||
|
||||
export type EntitaetsSchluessel = keyof typeof ENTITAETEN;
|
||||
|
||||
/** Die Domain, unter der das Backend seine Dienste anbietet:
|
||||
`audi_dashboard.<name>`. Früher `pyscript.audi_dashboard_<name>` - der
|
||||
doppelte Präfix war nur nötig, weil sich alle pyscript-Dienste eine Domain
|
||||
teilten. Siehe custom_components/audi_dashboard/dienste.py. */
|
||||
export const DIENST_DOMAIN = "audi_dashboard";
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Das Schema der Server-Adresse.
|
||||
*
|
||||
* Anlass: beim Neuverbinden stand „localhost:5173" im Feld — die App machte
|
||||
* daraus `https://localhost:5173` und meldete „Der Server ist unter dieser
|
||||
* Adresse nicht erreichbar". Das Beispiel im Feld selbst
|
||||
* (`192.168.1.20:8123`) hatte dasselbe Problem: im Heimnetz gibt es kein
|
||||
* Zertifikat, Home Assistant spricht dort `http`.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { basisUrlNormalisieren } from "./umgebung"
|
||||
|
||||
describe("basisUrlNormalisieren", () => {
|
||||
it("nimmt http fuer das eigene Netz", () => {
|
||||
// Genau die Vorlage aus dem Eingabefeld, und der gemeldete Fall.
|
||||
expect(basisUrlNormalisieren("192.168.1.20:8123")).toBe("http://192.168.1.20:8123")
|
||||
expect(basisUrlNormalisieren("localhost:5173")).toBe("http://localhost:5173")
|
||||
expect(basisUrlNormalisieren("127.0.0.1:8123")).toBe("http://127.0.0.1:8123")
|
||||
expect(basisUrlNormalisieren("10.0.0.5:8123")).toBe("http://10.0.0.5:8123")
|
||||
expect(basisUrlNormalisieren("172.16.3.4:8123")).toBe("http://172.16.3.4:8123")
|
||||
expect(basisUrlNormalisieren("homeassistant.local:8123")).toBe(
|
||||
"http://homeassistant.local:8123",
|
||||
)
|
||||
})
|
||||
|
||||
it("bleibt bei https fuer alles von aussen Erreichbare", () => {
|
||||
// Der echte Betriebsfall: die Domain laeuft ueber Cloudflare.
|
||||
expect(basisUrlNormalisieren("datametric360.de")).toBe("https://datametric360.de")
|
||||
expect(basisUrlNormalisieren("ha.example.com:8123")).toBe("https://ha.example.com:8123")
|
||||
// 172.32 liegt bereits AUSSERHALB des privaten Bereichs (nur 16-31).
|
||||
expect(basisUrlNormalisieren("172.32.0.1")).toBe("https://172.32.0.1")
|
||||
})
|
||||
|
||||
it("raet gar nicht, wenn ein Schema dasteht", () => {
|
||||
expect(basisUrlNormalisieren("https://192.168.1.20:8123")).toBe("https://192.168.1.20:8123")
|
||||
expect(basisUrlNormalisieren("http://datametric360.de")).toBe("http://datametric360.de")
|
||||
})
|
||||
|
||||
it("schneidet abschliessende Schraegstriche ab", () => {
|
||||
// Sonst entstehen Adressen wie "https://host//api/states".
|
||||
expect(basisUrlNormalisieren(" https://datametric360.de/// ")).toBe(
|
||||
"https://datametric360.de",
|
||||
)
|
||||
expect(basisUrlNormalisieren("192.168.1.20:8123/")).toBe("http://192.168.1.20:8123")
|
||||
})
|
||||
})
|
||||
@@ -101,7 +101,7 @@ const SCHLUESSEL_BASIS = "dm360.basis_url";
|
||||
const SCHLUESSEL_TOKEN = "dm360.token";
|
||||
|
||||
export interface Zugang {
|
||||
/** Basis-URL ohne abschließenden Schrägstrich, z. B. https://audi.datametric360.app */
|
||||
/** Basis-URL ohne abschließenden Schrägstrich, z. B. https://audi.datametric360.de */
|
||||
basisUrl: string;
|
||||
/** Long-Lived Access Token aus dem HA-Benutzerprofil. */
|
||||
token: string;
|
||||
@@ -129,9 +129,42 @@ export async function zugangVerwerfen(): Promise<void> {
|
||||
|
||||
/** Schneidet abschließende Schrägstriche ab und ergänzt fehlendes Schema.
|
||||
Ohne das entstehen sonst Adressen wie "https://host//api/states". */
|
||||
/**
|
||||
* Adressen, die per Definition im eigenen Netz liegen - dort gibt es kein
|
||||
* Zertifikat, also spricht der Server http.
|
||||
*
|
||||
* Home Assistant im Heimnetz ist genau dieser Fall: "192.168.1.20:8123" steht
|
||||
* sogar als Beispiel im Eingabefeld. Bis zum 04.09.2026 setzte die App davor
|
||||
* blind https:// - die eigene Vorlage, so eingetippt, ergab damit eine
|
||||
* Adresse, unter der nichts antwortet ("Der Server ist unter dieser Adresse
|
||||
* nicht erreichbar", gemeldet beim Neuverbinden).
|
||||
*
|
||||
* Umgekehrt bleibt https richtig fuer alles, was von aussen erreichbar ist -
|
||||
* datametric360.de laeuft ueber Cloudflare, dort waere http ein Rueckschritt.
|
||||
* Deshalb nicht generell umgestellt, sondern nach dem Ziel unterschieden.
|
||||
*/
|
||||
function imEigenenNetz(host: string): boolean {
|
||||
const h = host.toLowerCase().replace(/:[0-9]+$/, "").replace(/^\[|\]$/g, "");
|
||||
if (h === "localhost" || h === "::1") return true;
|
||||
if (h.endsWith(".local") || h.endsWith(".home.arpa") || h.endsWith(".localhost")) return true;
|
||||
const v4 = /^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/.exec(h);
|
||||
if (!v4) return false;
|
||||
const a = Number(v4[1]);
|
||||
const b = Number(v4[2]);
|
||||
return a === 127 || a === 10 || (a === 192 && b === 168) || (a === 172 && b >= 16 && b <= 31);
|
||||
}
|
||||
|
||||
/** Schneidet abschliessende Schraegstriche ab und ergaenzt fehlendes Schema.
|
||||
Ohne das entstehen sonst Adressen wie "https://host//api/states".
|
||||
|
||||
Geraten wird nur, wenn kein Schema dasteht - wer http:// oder https://
|
||||
tippt, bekommt genau das. */
|
||||
export function basisUrlNormalisieren(eingabe: string): string {
|
||||
let url = eingabe.trim();
|
||||
if (!/^https?:\/\//i.test(url)) url = `https://${url}`;
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
const ziel = url.split("/")[0] ?? "";
|
||||
url = `${imEigenenNetz(ziel) ? "http" : "https"}://${url}`;
|
||||
}
|
||||
return url.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Was passiert, wenn Home Assistant neu startet.
|
||||
*
|
||||
* Gemeldet vom Eigentümer (2026-08-31): während eines Neustarts zeigte die App
|
||||
* „502 Error“, und die Meldung blieb stehen, nachdem Home Assistant längst
|
||||
* wieder lief — erst ein Beenden der App half. Zwei Ursachen, beide hier
|
||||
* abgesichert:
|
||||
*
|
||||
* 1. 502 galt als harter Ladefehler statt als „Server kommt gerade hoch“.
|
||||
* 2. Nach dem Wiederverbinden wurde genau einmal nachgeladen. Schlug das
|
||||
* fehl (die Websocket-Verbindung steht regelmäßig ein paar Sekunden vor
|
||||
* der REST-Schnittstelle), kam nie wieder ein „verbunden“ — und damit nie
|
||||
* wieder ein Versuch.
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { ApiFehler, type DataMetricApi } from "../api"
|
||||
import { DatenAnbieter, useDaten } from "./DatenKontext"
|
||||
import { beispielApi } from "../tests/beispieldaten"
|
||||
|
||||
function Anzeige() {
|
||||
const { bereit, ladefehler } = useDaten()
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="bereit">{bereit ? "ja" : "nein"}</span>
|
||||
<span data-testid="fehler">{ladefehler ?? "-"}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Eine API, deren `profilLesen` sich auf Kommando wie ein gerade startender
|
||||
Home Assistant verhält: die nächsten n Aufrufe mit 502 abweisen, danach
|
||||
wieder normal antworten.
|
||||
|
||||
Die Fehlschläge werden bewusst gezählt und nicht an der Aufrufnummer
|
||||
festgemacht: der Horcher meldet beim Abonnieren sofort "verbunden", die
|
||||
Zählung liefe sonst schon beim Aufbau los. */
|
||||
function apiMitNeustart() {
|
||||
const echt = beispielApi()
|
||||
let verbindungHorcher: ((z: string) => void) | null = null
|
||||
let offeneFehlschlaege = 0
|
||||
const profilLesen = vi.fn(async () => {
|
||||
if (offeneFehlschlaege > 0) {
|
||||
offeneFehlschlaege -= 1
|
||||
throw new ApiFehler("Bad Gateway", 502)
|
||||
}
|
||||
return echt.profilLesen()
|
||||
})
|
||||
const api = {
|
||||
...echt,
|
||||
profilLesen,
|
||||
live: {
|
||||
...echt.live,
|
||||
aufVerbindung: (h: (z: string) => void) => {
|
||||
verbindungHorcher = h
|
||||
h("verbunden")
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
} as unknown as DataMetricApi
|
||||
return {
|
||||
api,
|
||||
profilLesen,
|
||||
/** Home Assistant startet neu: die nächsten n Ladeversuche laufen ins
|
||||
502, dann meldet sich die Websocket-Verbindung zurück. */
|
||||
neustartMelden: (fehlschlaege: number) => {
|
||||
offeneFehlschlaege = fehlschlaege
|
||||
verbindungHorcher?.("verbunden")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("Neustart von Home Assistant", () => {
|
||||
it("meldet 502 nicht als Ladefehler, solange Daten da sind", async () => {
|
||||
const { api, neustartMelden } = apiMitNeustart()
|
||||
render(
|
||||
<DatenAnbieter api={api}>
|
||||
<Anzeige />
|
||||
</DatenAnbieter>,
|
||||
)
|
||||
await waitFor(() => expect(screen.getByTestId("bereit").textContent).toBe("ja"))
|
||||
|
||||
neustartMelden(1)
|
||||
|
||||
// Der fehlgeschlagene Versuch darf keine Fehlerzeile hinterlassen.
|
||||
await waitFor(() => expect(screen.getByTestId("fehler").textContent).toBe("-"))
|
||||
expect(screen.getByTestId("bereit").textContent).toBe("ja")
|
||||
})
|
||||
|
||||
it("versucht es nach einem fehlgeschlagenen Nachladen erneut", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
try {
|
||||
const { api, profilLesen, neustartMelden } = apiMitNeustart()
|
||||
render(
|
||||
<DatenAnbieter api={api}>
|
||||
<Anzeige />
|
||||
</DatenAnbieter>,
|
||||
)
|
||||
await waitFor(() => expect(screen.getByTestId("bereit").textContent).toBe("ja"))
|
||||
const vorher = profilLesen.mock.calls.length
|
||||
|
||||
neustartMelden(2)
|
||||
await waitFor(() => expect(profilLesen.mock.calls.length).toBe(vorher + 1))
|
||||
|
||||
// Ohne Wiederholung bliebe es bei diesem einen Versuch — und die App
|
||||
// auf dem Stand von vor dem Neustart, bis jemand sie beendet.
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
await waitFor(() => expect(profilLesen.mock.calls.length).toBe(vorher + 2))
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
await waitFor(() => expect(profilLesen.mock.calls.length).toBe(vorher + 3))
|
||||
|
||||
expect(screen.getByTestId("fehler").textContent).toBe("-")
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("ApiFehler.istVoruebergehend", () => {
|
||||
it("gilt für kein Netz und für einen startenden Server", () => {
|
||||
expect(new ApiFehler("offline", null).istVoruebergehend).toBe(true)
|
||||
expect(new ApiFehler("bad gateway", 502).istVoruebergehend).toBe(true)
|
||||
expect(new ApiFehler("unavailable", 503).istVoruebergehend).toBe(true)
|
||||
expect(new ApiFehler("timeout", 504).istVoruebergehend).toBe(true)
|
||||
})
|
||||
|
||||
it("gilt nicht für einen echten Serverfehler", () => {
|
||||
// 500 bessert sich durch Warten nicht — das muss sichtbar bleiben.
|
||||
expect(new ApiFehler("kaputt", 500).istVoruebergehend).toBe(false)
|
||||
expect(new ApiFehler("nicht da", 404).istVoruebergehend).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -22,16 +22,32 @@ import {
|
||||
ApiFehler,
|
||||
DataMetricApi,
|
||||
ENTITAETEN,
|
||||
type Buendelangabe,
|
||||
type Fahrt,
|
||||
type Fahrzeugstatus,
|
||||
type IntegrationUpdateAngabe,
|
||||
type DongleAngabe,
|
||||
type LetzterDatensatz,
|
||||
type ZugangAngabe,
|
||||
type Profil,
|
||||
type Tankvorgang,
|
||||
type Verbindungszustand,
|
||||
type WartenderAuftrag,
|
||||
} from "../api"
|
||||
import { bildStaendeSetzen } from "../screens/bilder"
|
||||
import type { Versionsstand } from "./appVersion"
|
||||
import { eigeneVersion, versionVergleichen } from "./appVersion"
|
||||
import type { Einstellungen, Fahrzeug } from "./profilAdapter"
|
||||
import { profilZuEinstellungen, profilZuFahrzeug, zusammenfuehren } from "./profilAdapter"
|
||||
|
||||
/** Abstaende zwischen zwei Ladeversuchen nach einem Wiederverbinden.
|
||||
|
||||
Wachsend, in Millisekunden, zusammen gut zwei Minuten. Der erste
|
||||
Abstand ist kurz, weil Home Assistant nach einem Neustart meist binnen
|
||||
Sekunden antwortet; die spaeteren sind lang, damit ein wirklich
|
||||
abgestuerzter Server nicht dauernd angefragt wird. */
|
||||
const WIEDERHOLABSTAENDE_MS = [2000, 4000, 8000, 15000, 30000, 60000]
|
||||
|
||||
export interface DatenWert {
|
||||
bereit: boolean
|
||||
/** Erste Ladung fehlgeschlagen und noch nie Daten gesehen. */
|
||||
@@ -40,6 +56,28 @@ export interface DatenWert {
|
||||
warteschlange: readonly WartenderAuftrag[]
|
||||
/** Zeitpunkt der letzten Statusmeldung — treibt die Veraltet-Anzeige. */
|
||||
statusStand: string | null
|
||||
/** Ob diese App noch dem Stand entspricht, den das Backend ausliefert.
|
||||
Siehe appVersion.ts und VERSIONIERUNG.md. */
|
||||
versionsstand: Versionsstand
|
||||
/** Die vom Backend gemeldete Integrationsversion (`APP_VERSION`,
|
||||
`sensor.audi_dashboard_app_version`'s `app`-Feld), oder null vor der
|
||||
ersten Antwort. Panel-Gegenstück: die "Version"-Kachel in vEinst()
|
||||
("Installiert: ..."), die hier bislang fehlte, weil nur das
|
||||
Vergleichsergebnis (`versionsstand`), nie der Rohwert selbst,
|
||||
weitergereicht wurde. */
|
||||
serverVersion: string | null
|
||||
/** Das OTA-Bündel, das die Integration mitliefert (siehe daten/ota.ts),
|
||||
oder null — kein Fehler, sondern der Zustand ohne Bündel oder ohne
|
||||
Verbindung. */
|
||||
otaBuendel: Buendelangabe | null
|
||||
/** Ergebnis der letzten Selbst-Update-Prüfung/-Installation der
|
||||
Integration (aktualisierung.py), oder null vor der ersten Prüfung. */
|
||||
integrationUpdate: IntegrationUpdateAngabe | null
|
||||
zugaenge: ZugangAngabe[]
|
||||
/** Konfiguration des Dongles und eine ggf. vorgemerkte Aenderung
|
||||
(flespi.py). null, solange kein flespi-Token hinterlegt ist. */
|
||||
dongle: DongleAngabe | null
|
||||
letzterDatensatz: LetzterDatensatz | null
|
||||
|
||||
einstellungen: Einstellungen | null
|
||||
fahrzeug: Fahrzeug | null
|
||||
@@ -48,7 +86,10 @@ export interface DatenWert {
|
||||
rohprofil: Profil | null
|
||||
|
||||
api: DataMetricApi
|
||||
neuLaden: () => Promise<void>
|
||||
/** Laedt alles neu; `true`, wenn es geklappt hat. Aufrufer duerfen den
|
||||
Rueckgabewert ignorieren - er ist fuer die Wiederholung nach einem
|
||||
Wiederverbinden da. */
|
||||
neuLaden: () => Promise<boolean>
|
||||
jetztAktualisieren: () => Promise<void>
|
||||
/** Speichert geänderte Einstellungen/Fahrzeugdaten als ganzes Profil. */
|
||||
profilSpeichern: (aenderung: {
|
||||
@@ -81,13 +122,32 @@ export function DatenAnbieter({
|
||||
const [ladefehler, setzeLadefehler] = useState<string | null>(null)
|
||||
const [verbindung, setzeVerbindung] = useState<Verbindungszustand>("getrennt")
|
||||
const [warteschlange, setzeWarteschlange] = useState<readonly WartenderAuftrag[]>([])
|
||||
const [serverVersion, setzeServerVersion] = useState<string | null>(null)
|
||||
const [otaBuendel, setzeOtaBuendel] = useState<Buendelangabe | null>(null)
|
||||
const [zugaenge, setzeZugaenge] = useState<ZugangAngabe[]>([])
|
||||
const [dongle, setzeDongle] = useState<DongleAngabe | null>(null)
|
||||
const [letzterDatensatz, setzeLetzterDatensatz] = useState<LetzterDatensatz | null>(null)
|
||||
const [integrationUpdate, setzeIntegrationUpdate] = useState<IntegrationUpdateAngabe | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
// Damit profilSpeichern immer gegen den neuesten Rohstand arbeitet, auch
|
||||
// wenn zwischendurch ein Push hereinkam.
|
||||
const rohprofilRef = useRef<Profil | null>(null)
|
||||
rohprofilRef.current = rohprofil
|
||||
|
||||
const neuLaden = useCallback(async () => {
|
||||
// Dasselbe fuer `bereit`: neuLaden liest den Wert nur, darf aber nicht
|
||||
// davon abhaengen. Stand er in den Abhaengigkeiten, bekaeme der Effekt
|
||||
// unten (der bewusst nur an `api` haengt) fuer immer die Fassung vom
|
||||
// ersten Rendern mit `bereit === false` - und wuerde nach einem
|
||||
// Wiederverbinden jeden Aussetzer als harten Ladefehler melden, obwohl
|
||||
// laengst Daten da sind.
|
||||
const bereitRef = useRef(false)
|
||||
bereitRef.current = bereit
|
||||
|
||||
/** Laedt alles neu. Gibt zurueck, ob es geklappt hat - der Aufrufer
|
||||
entscheidet, ob er es noch einmal versucht. */
|
||||
const neuLaden = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const [p, f, t, s] = await Promise.all([
|
||||
api.profilLesen(),
|
||||
@@ -102,18 +162,54 @@ export function DatenAnbieter({
|
||||
setzeStatusStand(s.last_updated)
|
||||
setzeLadefehler(null)
|
||||
setzeBereit(true)
|
||||
// Bewusst außerhalb des Promise.all oben und ohne eigenes catch hier:
|
||||
// appVersionAngabeLesen() schluckt seine Fehler selbst und liefert dann
|
||||
// null. Ein fehlender Versionsvergleich darf die Erstladung nie
|
||||
// aufhalten - er ist ein Hinweis, keine Betriebsvoraussetzung.
|
||||
const versionsangabe = await api.appVersionAngabeLesen()
|
||||
setzeServerVersion(versionsangabe?.app ?? null)
|
||||
setzeOtaBuendel(versionsangabe?.buendel ?? null)
|
||||
setzeIntegrationUpdate(versionsangabe?.integration_update ?? null)
|
||||
setzeZugaenge(versionsangabe?.zugaenge ?? [])
|
||||
setzeDongle(versionsangabe?.dongle ?? null)
|
||||
setzeLetzterDatensatz(versionsangabe?.letzter_datensatz ?? null)
|
||||
// Kein React-Zustand: die Bildadressen werden beim Rendern gebraucht,
|
||||
// teils ausserhalb von Komponenten (bilderVorladen) - dieselbe
|
||||
// Ueberlegung wie bei zugangMerken().
|
||||
bildStaendeSetzen(versionsangabe?.bildstaende ?? null)
|
||||
return 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) {
|
||||
// Ein voruebergehender Ausfall ist kein Ladefehler, solange schon
|
||||
// Daten da sind — dann zeigt die App den zwischengespeicherten Stand
|
||||
// mit Offline-Hinweis. Dazu zaehlt auch ein Home Assistant, der
|
||||
// gerade neu startet (502/503/504), siehe istVoruebergehend.
|
||||
const wartet = fehler instanceof ApiFehler && fehler.istVoruebergehend
|
||||
if (!wartet || !bereitRef.current) {
|
||||
setzeLadefehler(fehler instanceof Error ? fehler.message : String(fehler))
|
||||
}
|
||||
return false
|
||||
}
|
||||
}, [api, bereit])
|
||||
}, [api])
|
||||
|
||||
// Erstladung plus Live-Verbindung.
|
||||
useEffect(() => {
|
||||
let abgemeldet = false
|
||||
let nachladeUhr: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/** Versucht es so lange, bis eine Ladung durchgeht - mit wachsendem
|
||||
Abstand, damit ein dauerhaft nicht erreichbarer Server nicht im
|
||||
Sekundentakt angefragt wird. Nach gut zwei Minuten ist Schluss;
|
||||
dann hilft nur noch das naechste "verbunden" oder ein Zug nach
|
||||
unten auf der Uebersicht. */
|
||||
const nachladenBisEsKlappt = async (versuch = 0): Promise<void> => {
|
||||
if (abgemeldet) return
|
||||
if (await neuLaden()) return
|
||||
if (abgemeldet || versuch >= WIEDERHOLABSTAENDE_MS.length) return
|
||||
nachladeUhr = setTimeout(() => {
|
||||
void nachladenBisEsKlappt(versuch + 1)
|
||||
}, WIEDERHOLABSTAENDE_MS[versuch])
|
||||
}
|
||||
|
||||
void neuLaden()
|
||||
void api.warteschlange.laden()
|
||||
|
||||
@@ -121,7 +217,15 @@ export function DatenAnbieter({
|
||||
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()
|
||||
//
|
||||
// Warum das nicht ein einzelner Aufruf sein darf: nach einem Neustart
|
||||
// von Home Assistant steht die Websocket-Verbindung regelmaessig ein
|
||||
// paar Sekunden, bevor die REST-Schnittstelle antwortet - dazwischen
|
||||
// kommt 502. Ein einziger Versuch schlug dann fehl, und weil danach
|
||||
// kein weiteres "verbunden" mehr folgt, blieb die App bis zum
|
||||
// Beenden auf dem Stand von vor dem Neustart stehen, mitsamt der
|
||||
// Fehlerzeile (vom Eigentuemer gemeldet, 2026-08-31).
|
||||
if (zustand === "verbunden") void nachladenBisEsKlappt()
|
||||
})
|
||||
|
||||
const abZustand = api.live.aufZustand(({ entity_id, new_state }) => {
|
||||
@@ -150,6 +254,8 @@ export function DatenAnbieter({
|
||||
void api.live.starten()
|
||||
|
||||
return () => {
|
||||
abgemeldet = true
|
||||
if (nachladeUhr) clearTimeout(nachladeUhr)
|
||||
abVerbindung()
|
||||
abZustand()
|
||||
abWarteschlange()
|
||||
@@ -198,6 +304,13 @@ export function DatenAnbieter({
|
||||
verbindung,
|
||||
warteschlange,
|
||||
statusStand,
|
||||
versionsstand: versionVergleichen(eigeneVersion(), serverVersion).stand,
|
||||
serverVersion,
|
||||
otaBuendel,
|
||||
integrationUpdate,
|
||||
zugaenge,
|
||||
dongle,
|
||||
letzterDatensatz,
|
||||
einstellungen,
|
||||
fahrzeug,
|
||||
fahrten,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { versionOrdnung, versionVergleichen } from "./appVersion"
|
||||
|
||||
/* Der Vergleich entscheidet, ob die App dem Nutzer sagt „ich bin älter als der
|
||||
Server". Beide Fehlrichtungen sind teuer: ein Fehlalarm schickt ihn grundlos
|
||||
an Xcode, ein verschluckter Hinweis lässt genau die stille Veraltung zu, die
|
||||
die ganze Mechanik verhindern soll. */
|
||||
describe("versionVergleichen", () => {
|
||||
it("meldet Gleichstand bei identischer Version", () => {
|
||||
const v = versionVergleichen("2026.08.23.1", "2026.08.23.1")
|
||||
expect(v.stand).toBe("gleich")
|
||||
})
|
||||
|
||||
it("nennt die Richtung, statt nur eine Abweichung zu melden", () => {
|
||||
expect(versionVergleichen("2026.08.23.1", "2026.08.24.1").stand).toBe("aelter")
|
||||
expect(versionVergleichen("2026.08.23.2", "2026.08.23.1").stand).toBe("neuer")
|
||||
})
|
||||
|
||||
it("meldet ohne erkennbares Format eine Abweichung ohne Richtung", () => {
|
||||
// Der Einwand, der bis zum 04.09.2026 GEGEN jede Sortierung sprach: bei
|
||||
// einem Formatwechsel darf nichts erfunden werden.
|
||||
expect(versionVergleichen("2026.8.23.1", "fruehling-2").stand).toBe("abweichend")
|
||||
})
|
||||
|
||||
it("vergleicht nicht, wenn das Backend nichts meldet", () => {
|
||||
// Altes Backend ohne die Versions-Entität, oder offline. Ein Hinweis waere
|
||||
// hier geraten, nicht gewusst.
|
||||
expect(versionVergleichen("2026.08.23.1", null).stand).toBe("unbekannt")
|
||||
})
|
||||
|
||||
it("vergleicht nicht, wenn die App ihre eigene Version nicht kennt", () => {
|
||||
expect(versionVergleichen(null, "2026.08.23.1").stand).toBe("unbekannt")
|
||||
})
|
||||
|
||||
it("behandelt eine leere Zeichenkette wie fehlend, nicht wie einen Wert", () => {
|
||||
expect(versionVergleichen("", "2026.08.23.1").stand).toBe("unbekannt")
|
||||
expect(versionVergleichen("2026.08.23.1", "").stand).toBe("unbekannt")
|
||||
})
|
||||
|
||||
it("gibt beide Staende zur Anzeige zurueck", () => {
|
||||
const v = versionVergleichen("2026.08.23.1", "2026.08.24.1")
|
||||
expect(v.app).toBe("2026.08.23.1")
|
||||
expect(v.server).toBe("2026.08.24.1")
|
||||
})
|
||||
})
|
||||
|
||||
/* versionOrdnung() ist die Stelle, an der aus zwei Kennungen ein Vorher und
|
||||
ein Nachher wird. Sie entscheidet, ob der Update-Knopf erscheint - und ein
|
||||
Vorzeichenfehler hier böte einen Rückschritt an, wie am 04.09.2026 real
|
||||
geschehen (App .17, Instanz .5). */
|
||||
describe("versionOrdnung", () => {
|
||||
it("erkennt die letzte Stelle, auch zweistellig", () => {
|
||||
// Genau der gemeldete Fall: 5 gegen 17. Zeichenweise verglichen stuende
|
||||
// "17" vor "5" - deshalb wird je Stelle als Zahl verglichen.
|
||||
expect(versionOrdnung("2026.9.4.5", "2026.9.4.17")).toBe(-1)
|
||||
expect(versionOrdnung("2026.9.4.17", "2026.9.4.5")).toBe(1)
|
||||
})
|
||||
|
||||
it("vergleicht Stelle für Stelle von links", () => {
|
||||
expect(versionOrdnung("2026.8.31.9", "2026.9.1.1")).toBe(-1)
|
||||
expect(versionOrdnung("2026.9.1.1", "2025.12.31.9")).toBe(1)
|
||||
})
|
||||
|
||||
it("behandelt fehlende Stellen als Null", () => {
|
||||
expect(versionOrdnung("2026.9.4", "2026.9.4.0")).toBe(0)
|
||||
expect(versionOrdnung("2026.9.4", "2026.9.4.1")).toBe(-1)
|
||||
})
|
||||
|
||||
it("stoert sich nicht an fuehrenden Nullen", () => {
|
||||
expect(versionOrdnung("2026.08.23.1", "2026.8.23.1")).toBe(0)
|
||||
})
|
||||
|
||||
it("sortiert nicht, was nicht ins Format passt", () => {
|
||||
// parseInt haette "4b" klaglos als 4 gelesen - genau die scheingenaue
|
||||
// Antwort, die hier nicht entstehen darf.
|
||||
expect(versionOrdnung("2026.9.4b", "2026.9.5")).toBeNull()
|
||||
expect(versionOrdnung("v2026.9.4", "2026.9.5")).toBeNull()
|
||||
expect(versionOrdnung("2026..4", "2026.9.4")).toBeNull()
|
||||
})
|
||||
|
||||
it("nennt zeichengleiche Kennungen gleich, auch ohne lesbares Format", () => {
|
||||
expect(versionOrdnung("fruehling-2", "fruehling-2")).toBe(0)
|
||||
})
|
||||
|
||||
it("entscheidet nichts, wenn eine Seite fehlt", () => {
|
||||
expect(versionOrdnung(null, "2026.9.4.1")).toBeNull()
|
||||
expect(versionOrdnung("2026.9.4.1", "")).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Vergleich zwischen dem Stand, den diese App mitbringt, und dem, den das
|
||||
* Backend ausliefert. Hintergrund: ../../VERSIONIERUNG.md
|
||||
*
|
||||
* Warum es das braucht: Das Panel lädt seine Dateien bei jedem Seitenaufruf
|
||||
* frisch und kann gar nicht veralten. Die iOS-App trägt ihre Dateien fest
|
||||
* gebündelt und bleibt auf dem Stand vom Signieren — unbegrenzt und bisher
|
||||
* unbemerkt. Diese Datei macht daraus ein sichtbares Signal.
|
||||
*
|
||||
* ENTSCHEIDUNG GEÄNDERT AM 04.09.2026 — vorher stand hier ausdrücklich
|
||||
* „bewusst kein größer/kleiner-Vergleich: die Version ist eine Kennung, keine
|
||||
* Zahl". Der Anlass war ein echter Fall: nach einem Xcode-Lauf lief die App
|
||||
* auf `2026.9.4.17`, die Instanz stand noch auf `2026.9.4.5` — und die App bot
|
||||
* an, sich auf `.5` zu „erneuern". Ein Rückschritt, den die Gleichheitsprüfung
|
||||
* gar nicht sehen konnte. Vorgabe des Eigentümers: erkannt werden soll eine
|
||||
* NEUERE Fassung und keine andere.
|
||||
*
|
||||
* Der damalige Einwand bleibt gültig und ist deshalb eingebaut, statt
|
||||
* verworfen: sortiert wird nur, was dem Format `JJJJ.M.T.N` entspricht. Passt
|
||||
* eine der beiden Zeichenketten nicht ins Schema (Formatwechsel), liefert der
|
||||
* Vergleich „abweichend" statt einer erfundenen Reihenfolge — dann sagt die
|
||||
* App, dass etwas nicht übereinstimmt, aber nicht, in welche Richtung.
|
||||
*/
|
||||
|
||||
export type Versionsstand =
|
||||
/** App und Backend melden denselben Stand. */
|
||||
| "gleich"
|
||||
/** Die App ist nachweislich älter — nur hier gibt es etwas zu holen. */
|
||||
| "aelter"
|
||||
/** Die App ist nachweislich neuer, etwa direkt nach einem Xcode-Lauf,
|
||||
solange die Instanz noch nicht nachgezogen hat. Kein Handlungsbedarf. */
|
||||
| "neuer"
|
||||
/** Verschieden, aber nicht in eine Reihenfolge zu bringen (Formatwechsel). */
|
||||
| "abweichend"
|
||||
/** Kein Vergleich möglich (Backend meldet nichts, offline, altes Backend). */
|
||||
| "unbekannt"
|
||||
|
||||
export interface Versionsvergleich {
|
||||
stand: Versionsstand
|
||||
app: string | null
|
||||
server: string | null
|
||||
}
|
||||
|
||||
/** Die in diese App einkompilierte Version (vite, siehe vite.config.ts). */
|
||||
export function eigeneVersion(): string | null {
|
||||
// Der Zugriff ist abgesichert, weil die Konstante in Testläufen und im
|
||||
// Dev-Server nicht zwingend gesetzt ist. Fehlt sie, wird nicht verglichen,
|
||||
// statt eine Abweichung zu behaupten.
|
||||
return typeof __APP_VERSION__ === "string" && __APP_VERSION__ ? __APP_VERSION__ : null
|
||||
}
|
||||
|
||||
/** Zerlegt `2026.9.4.17` in [2026, 9, 4, 17]. `null`, sobald ein Teil keine
|
||||
reine Ziffernfolge ist — dann wird nicht sortiert (siehe Modulkopf). */
|
||||
function teile(version: string): number[] | null {
|
||||
const stuecke = version.split(".")
|
||||
if (stuecke.length === 0) return null
|
||||
const zahlen: number[] = []
|
||||
for (const stueck of stuecke) {
|
||||
// Bewusst kein parseInt: das würde "4b" klaglos als 4 lesen und damit
|
||||
// genau die scheingenaue Sortierung erzeugen, vor der der Modulkopf warnt.
|
||||
if (!/^[0-9]+$/.test(stueck)) return null
|
||||
zahlen.push(Number(stueck))
|
||||
}
|
||||
return zahlen
|
||||
}
|
||||
|
||||
/**
|
||||
* `-1` wenn `a` älter ist als `b`, `0` bei Gleichstand, `+1` wenn `a` neuer
|
||||
* ist — und `null`, wenn sich das nicht entscheiden lässt.
|
||||
*
|
||||
* Fehlende Stellen zählen als 0, damit `2026.9.4` und `2026.9.4.0` denselben
|
||||
* Stand bezeichnen.
|
||||
*/
|
||||
export function versionOrdnung(a: string | null, b: string | null): number | null {
|
||||
if (!a || !b) return null
|
||||
// Zeichengleich ist gleich, auch wenn das Format unbekannt ist.
|
||||
if (a === b) return 0
|
||||
const links = teile(a)
|
||||
const rechts = teile(b)
|
||||
if (!links || !rechts) return null
|
||||
const laenge = Math.max(links.length, rechts.length)
|
||||
for (let i = 0; i < laenge; i++) {
|
||||
const l = links[i] ?? 0
|
||||
const r = rechts[i] ?? 0
|
||||
if (l !== r) return l < r ? -1 : 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
export function versionVergleichen(
|
||||
app: string | null,
|
||||
server: string | null,
|
||||
): Versionsvergleich {
|
||||
if (!app || !server) return { stand: "unbekannt", app, server }
|
||||
const ordnung = versionOrdnung(app, server)
|
||||
if (ordnung === null) return { stand: "abweichend", app, server }
|
||||
if (ordnung === 0) return { stand: "gleich", app, server }
|
||||
return { stand: ordnung < 0 ? "aelter" : "neuer", app, server }
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Tests zur Uebernahme eines eingelesenen Belegs ins Tankformular.
|
||||
*
|
||||
* Die Nutzlast unten ist KEIN erfundenes Beispiel: sie ist die Ausgabe von
|
||||
* shell_beleg_parser._parsen() fuer den Beleg, den der Eigentuemer am
|
||||
* 04.09.2026 ueber das Teilen-Blatt hereingereicht hat und der in der App nur
|
||||
* mit Datum und Uhrzeit ankam (Shell, Richard-Wagner-Str. 9, Ingolstadt,
|
||||
* 43,87 l fuer 95,60 EUR). Genau dieser Fall gehoert in einen Test.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { belegFormularwerte } from "./belegentwurf"
|
||||
|
||||
/* Wortgleich mit dem, was belege._beleg_felder() fuer diesen Beleg
|
||||
veroeffentlicht - die Feldnamen sind der Punkt dieser Suite. */
|
||||
const GETEILT = {
|
||||
receipt_key: "0000000459_3368-082-00082_2026-04-03T00:55",
|
||||
receipt_no: "3368/082/00082",
|
||||
tse_beleg_nr: "73912",
|
||||
ts: "2026-04-03T00:55:00",
|
||||
ts_payment: "2026-04-03T00:52:43",
|
||||
ts_tse: "2026-04-03T00:55:01",
|
||||
station_id: "0000000459",
|
||||
station_brand: "Shell",
|
||||
station_name: "Shell, Richard-Wagner-Str. 9, Ingolstadt",
|
||||
station_address: "Richard-Wagner-Str. 9, 85057 Ingolstadt",
|
||||
article_no: "000013",
|
||||
product_name: "V-Power Racing",
|
||||
fuel_type: "V-Power Racing",
|
||||
liters: 43.87,
|
||||
fuel_total_eur: 95.6,
|
||||
price_per_l: 2.1792,
|
||||
discount: 11.84,
|
||||
discount_per_l: 0.27,
|
||||
list_price_per_l: 2.449,
|
||||
receipt_total_eur: 95.6,
|
||||
net_eur: 80.34,
|
||||
vat_eur: 15.26,
|
||||
receipt_file: "/config/audi_dashboard/belege/e-receipt (7).pdf",
|
||||
}
|
||||
|
||||
describe("belegFormularwerte", () => {
|
||||
it("fuellt das ganze Formular, nicht nur Datum und Uhrzeit", () => {
|
||||
// Der gemeldete Fehler: alles ausser zeitpunkt blieb leer, weil die App
|
||||
// liter/kosten/station/ersparnis/kraftstoff abfragte statt der
|
||||
// Belegfelder.
|
||||
expect(belegFormularwerte(GETEILT)).toEqual({
|
||||
zeitpunkt: "2026-04-03T00:55",
|
||||
liter: "43.87",
|
||||
kosten: "95.6",
|
||||
ersparnis: "11.84",
|
||||
station: "Shell, Richard-Wagner-Str. 9, Ingolstadt",
|
||||
kraftstoff: "V-Power Racing",
|
||||
})
|
||||
})
|
||||
|
||||
it("nimmt den realen Betrag, nicht den Listenpreis", () => {
|
||||
// fuel_total_eur ist bereits rabattiert; receipt_total_eur waere hier
|
||||
// zufaellig gleich, list_price_per_l dagegen ein Preis je Liter.
|
||||
expect(belegFormularwerte(GETEILT).kosten).toBe(String(GETEILT.fuel_total_eur))
|
||||
})
|
||||
|
||||
it("laesst Felder leer, die der Beleg nicht nennt", () => {
|
||||
// Ein Beleg ohne Rabatt ist normal (BelegOhneRabatt im Parsertest) - das
|
||||
// Formularfeld soll dann nicht auf "null" stehen.
|
||||
expect(belegFormularwerte({ ...GETEILT, discount: null })).toMatchObject({ ersparnis: "" })
|
||||
expect(belegFormularwerte({ ts: "2026-04-03T00:55:00" })).toEqual({
|
||||
zeitpunkt: "2026-04-03T00:55",
|
||||
liter: "",
|
||||
kosten: "",
|
||||
ersparnis: "",
|
||||
station: "",
|
||||
kraftstoff: "",
|
||||
})
|
||||
})
|
||||
|
||||
it("laesst eine echte Null durch", () => {
|
||||
// 0 ist ein Wert, kein fehlendes Feld - ein Beleg mit 0,00 EUR Rabatt
|
||||
// darf nicht wie "kein Rabatt angegeben" aussehen.
|
||||
expect(belegFormularwerte({ ...GETEILT, discount: 0 }).ersparnis).toBe("0")
|
||||
})
|
||||
|
||||
it("kommt mit gar keinem Beleg zurecht", () => {
|
||||
expect(belegFormularwerte(null).zeitpunkt).toBe("")
|
||||
expect(belegFormularwerte(undefined).liter).toBe("")
|
||||
})
|
||||
|
||||
it("uebernimmt keinen Kilometerstand", () => {
|
||||
// SPECIFICATION.md §7.7 Regel 2: der kommt immer vom Fahrzeug.
|
||||
expect(Object.keys(belegFormularwerte(GETEILT))).not.toContain("km")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Was ein eingelesener Beleg ins Tankformular traegt.
|
||||
*
|
||||
* WARUM DAS HIER STEHT UND NICHT IM BILDSCHIRM
|
||||
* -------------------------------------------
|
||||
* Bis zum 04.09.2026 stand die Uebernahme als Folge von `if`-Zeilen in
|
||||
* `Tanken.tsx` - und fragte `liter`/`kosten`/`station`/`ersparnis`/
|
||||
* `kraftstoff` ab, also die Namen der FORMULARFELDER. Veroeffentlicht werden
|
||||
* aber die Namen des BELEGS (`_beleg_felder` in belege.py, dieselben, die auch
|
||||
* im Tankvorgang landen). Uebereingestimmt hat allein `ts`.
|
||||
*
|
||||
* Sichtbar war das als: ein eingelesener Beleg fuellte Datum und Uhrzeit,
|
||||
* sonst nichts (Befund des Eigentuemers, 04.09.2026 - gemeldet fuer einen
|
||||
* ueber das Teilen-Blatt hereingereichten Beleg, betraf aber jeden Weg
|
||||
* gleichermassen, weil beide durch `belegLesen()` laufen). Als eigene
|
||||
* Funktion laesst sich die Zuordnung gegen eine echte Nutzlast pruefen; in
|
||||
* den `if`-Zeilen des Bildschirms konnte sie es nicht.
|
||||
*
|
||||
* KEIN KILOMETERSTAND
|
||||
* -------------------
|
||||
* Er kommt immer vom Fahrzeug, nie vom Beleg (SPECIFICATION.md §7.7 Regel 2).
|
||||
* Das Panel haelt es in `tankFelder()` genauso.
|
||||
*/
|
||||
|
||||
/** Die Felder, die belege.py nach dem Lesen eines Belegs veroeffentlicht. */
|
||||
export interface BelegEntwurf {
|
||||
ts?: string
|
||||
liters?: number | null
|
||||
/** Der reale, bereits rabattierte Betrag. */
|
||||
fuel_total_eur?: number | null
|
||||
/** SmartDeal-Ersparnis. */
|
||||
discount?: number | null
|
||||
station_name?: string | null
|
||||
fuel_type?: string | null
|
||||
receipt_key?: string | null
|
||||
receipt_file?: string | null
|
||||
}
|
||||
|
||||
/** Was davon in welches Formularfeld geht - leere Zeichenkette heisst: nicht
|
||||
setzen, das Feld behaelt seinen bisherigen Wert. */
|
||||
export interface Formularwerte {
|
||||
zeitpunkt: string
|
||||
liter: string
|
||||
kosten: string
|
||||
ersparnis: string
|
||||
station: string
|
||||
kraftstoff: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Zahl in die Zeichenkette eines Zahlenfelds. `0` ist ein gueltiger Wert und
|
||||
* muss durchkommen - nur `null`/`undefined` bedeuten "steht nicht im Beleg".
|
||||
*/
|
||||
function zahl(wert: number | null | undefined): string {
|
||||
return wert == null ? "" : String(wert)
|
||||
}
|
||||
|
||||
export function belegFormularwerte(e: BelegEntwurf | null | undefined): Formularwerte {
|
||||
return {
|
||||
// Das Eingabefeld will "JJJJ-MM-TTThh:mm", der Beleg liefert Sekunden mit.
|
||||
zeitpunkt: e?.ts ? e.ts.slice(0, 16) : "",
|
||||
liter: zahl(e?.liters),
|
||||
kosten: zahl(e?.fuel_total_eur),
|
||||
ersparnis: zahl(e?.discount),
|
||||
station: e?.station_name ?? "",
|
||||
kraftstoff: e?.fuel_type ?? "",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Das OTA-Bündel muss dieselbe Fassung tragen wie das Manifest der
|
||||
* Integration.
|
||||
*
|
||||
* Am 05.09.2026 am iPhone aufgefallen: der Server meldete 2026.9.5.29, das
|
||||
* mitgelieferte Bündel war noch 2026.9.5.28. `buendelPasst()` verlangt
|
||||
* Gleichheit — die App hielt sich deshalb für nicht erneuerbar und schickte
|
||||
* den Eigentümer zu Xcode, obwohl nur die Veröffentlichung unvollständig war:
|
||||
* die Versionsnummer war erhöht worden, ohne `npm run ota` laufen zu lassen.
|
||||
*
|
||||
* Dieser Test macht daraus einen Fehlschlag vor der Auslieferung statt einer
|
||||
* irreführenden Meldung auf dem Telefon.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
const lies = (pfad: string) => JSON.parse(readFileSync(pfad, "utf8")) as { version?: string }
|
||||
|
||||
describe("OTA-Bündel und Manifest", () => {
|
||||
it("tragen dieselbe Versionsnummer", () => {
|
||||
const manifest = lies("../custom_components/audi_dashboard/manifest.json")
|
||||
const buendel = lies("../custom_components/audi_dashboard/frontend/app/bundle.json")
|
||||
expect(buendel.version).toBe(manifest.version)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Die Modellliste der App muss der des Panels entsprechen.
|
||||
*
|
||||
* Bis zum 05.09.2026 war Modell in der App ein freies Textfeld, im Panel ein
|
||||
* Auswahlfeld - vom Eigentümer bemerkt. Beim Nachziehen entstand eine
|
||||
* Zweitschrift der Liste; dieser Test schneidet die Panel-Fassung aus der
|
||||
* Quelle heraus und vergleicht sie, damit die beiden nicht auseinanderlaufen.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { FAHRZEUG_MODELLE, modellauswahl } from "./fahrzeugmodelle"
|
||||
|
||||
function panelListe(): string[] {
|
||||
const quelle = readFileSync(
|
||||
"../custom_components/audi_dashboard/frontend/audi-dashboard-app.js",
|
||||
"utf8",
|
||||
)
|
||||
const start = quelle.indexOf("const FAHRZEUG_MODELLE = [")
|
||||
if (start < 0) throw new Error("FAHRZEUG_MODELLE nicht in der Panel-Quelle gefunden")
|
||||
const ende = quelle.indexOf("];", start)
|
||||
const roh = quelle.slice(start, ende)
|
||||
return [...roh.matchAll(/"([^"]+)"/g)].map((m) => m[1] as string)
|
||||
}
|
||||
|
||||
describe("Modellliste", () => {
|
||||
it("stimmt mit der des Panels überein", () => {
|
||||
expect([...FAHRZEUG_MODELLE]).toEqual(panelListe())
|
||||
})
|
||||
|
||||
it("stellt einen unbekannten gespeicherten Titel voran", () => {
|
||||
const auswahl = modellauswahl("RS 6 Avant performance")
|
||||
expect(auswahl[0]).toBe("RS 6 Avant performance")
|
||||
expect(auswahl).toHaveLength(FAHRZEUG_MODELLE.length + 1)
|
||||
})
|
||||
|
||||
it("laesst die Liste unveraendert, wenn der Titel darin steht", () => {
|
||||
expect(modellauswahl("RS 4 Avant")).toEqual(FAHRZEUG_MODELLE)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Die Modelle, zu denen ein echtes Badge vorliegt.
|
||||
*
|
||||
* Zweitschrift von `FAHRZEUG_MODELLE` im Panel
|
||||
* (custom_components/audi_dashboard/frontend/audi-dashboard-app.js). Zwei
|
||||
* Codebasen, eine Liste - dass sie gleich bleiben, sichert
|
||||
* `fahrzeugmodelle.test.ts`, der die Panel-Quelle einliest und Eintrag für
|
||||
* Eintrag vergleicht.
|
||||
*
|
||||
* Beschränkt auf Modelle mit Badge: für alles andere stünde ein falsches oder
|
||||
* gar kein Badge auf der Übersicht. Die Ausführung („competition",
|
||||
* „performance") gehört nicht hierher, sondern in das Feld „Ausführung" -
|
||||
* Entscheidung des Eigentümers vom 05.09.2026.
|
||||
*/
|
||||
export const FAHRZEUG_MODELLE = [
|
||||
"RS 3 Sportback",
|
||||
"RS 3 Limousine",
|
||||
"RS 4 Avant",
|
||||
"RS 5 Coupé",
|
||||
"RS 5 Sportback",
|
||||
"RS 6 Avant",
|
||||
"RS 7 Sportback",
|
||||
"S3 Sportback",
|
||||
"S3 Limousine",
|
||||
"SQ7",
|
||||
] as const
|
||||
|
||||
/** Die Liste, wie sie im Auswahlfeld stehen muss.
|
||||
*
|
||||
* Ein gespeicherter Titel, der nicht (mehr) in der Liste steht, wird
|
||||
* vorangestellt statt verschluckt - sonst fiele die Auswahl still auf den
|
||||
* ersten Eintrag zurück und änderte das Fahrzeug des Nutzers. Wortgleich mit
|
||||
* dem Panel. */
|
||||
export function modellauswahl(titel: string): readonly string[] {
|
||||
return FAHRZEUG_MODELLE.includes(titel as (typeof FAHRZEUG_MODELLE)[number])
|
||||
? FAHRZEUG_MODELLE
|
||||
: [titel, ...FAHRZEUG_MODELLE]
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Adresse → Koordinaten (Vorwärts-Geokodierung über Nominatim), mit Cache im
|
||||
* localStorage. Gegenstück zur Rückwärts-Auflösung in `Standort.tsx`.
|
||||
*
|
||||
* Gebraucht wird sie für die Tankstellennadel auf dem Einzelbeleg: der Beleg
|
||||
* trägt nur eine Anschrift, und das Backend füllt `station_lat`/`station_lon`
|
||||
* nie — ohne diesen Schritt bliebe die Karte dort dauerhaft ohne Nadel. Das
|
||||
* Panel macht es seit jeher genau so (`adresseAufloesen()`); der App fehlte
|
||||
* beides, Karte wie Auflösung.
|
||||
*
|
||||
* Der Cache liegt bewusst im localStorage und nicht nur im Speicher: eine
|
||||
* Tankstellenadresse ändert ihre Lage nicht, und Nominatim bittet in seinen
|
||||
* Nutzungsbedingungen ausdrücklich darum, Ergebnisse zu behalten. Auch ein
|
||||
* erfolgloser Treffer wird als `null` vermerkt, damit eine Adresse, die
|
||||
* Nominatim nicht kennt, nicht bei jedem Öffnen erneut abgefragt wird — ein
|
||||
* Netzfehler dagegen nicht, der ist kein „gibt es nicht".
|
||||
*/
|
||||
|
||||
export interface Koordinate {
|
||||
lat: number
|
||||
lon: number
|
||||
}
|
||||
|
||||
const CACHE_SCHLUESSEL = "dm360.geocache"
|
||||
|
||||
/* Arten, bei denen der Eigenname aussagekräftiger ist als die Anschrift
|
||||
(Feld type der Nominatim-Antwort). */
|
||||
const PLATZ_ARTEN = new Set([
|
||||
"parking",
|
||||
"parking_space",
|
||||
"parking_entrance",
|
||||
"bicycle_parking",
|
||||
"motorcycle_parking",
|
||||
"rest_area",
|
||||
"services",
|
||||
"fuel",
|
||||
"charging_station",
|
||||
])
|
||||
|
||||
function cacheLesen(): Record<string, Koordinate | string | null> {
|
||||
try {
|
||||
const roh = localStorage.getItem(CACHE_SCHLUESSEL)
|
||||
return roh ? (JSON.parse(roh) as Record<string, Koordinate | string | null>) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/** Adresstext im selben Ablagefach merken (Schlüssel mit "rev:"-Vorsatz). */
|
||||
function adresseMerken(schluessel: string, adresse: string): void {
|
||||
try {
|
||||
const roh = localStorage.getItem(CACHE_SCHLUESSEL)
|
||||
const cache = roh ? (JSON.parse(roh) as Record<string, unknown>) : {}
|
||||
cache[schluessel] = adresse
|
||||
localStorage.setItem(CACHE_SCHLUESSEL, JSON.stringify(cache))
|
||||
} catch {
|
||||
// Privatmodus oder volle Ablage — dann eben ohne Cache.
|
||||
}
|
||||
}
|
||||
|
||||
function cacheSchreiben(suche: string, treffer: Koordinate | null): void {
|
||||
try {
|
||||
const cache = cacheLesen()
|
||||
cache[suche] = treffer
|
||||
localStorage.setItem(CACHE_SCHLUESSEL, JSON.stringify(cache))
|
||||
} catch {
|
||||
// Privatmodus oder volle Ablage — dann eben ohne Cache.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Koordinaten → Adresse. Der Cache ist hier genauso wichtig wie bei der
|
||||
* Vorwärtsrichtung, aus einem anderen Grund: Nominatim drosselt und sperrt
|
||||
* bei häufigen Abrufen, und die Antwort kommt dann ohne CORS-Kopf zurück —
|
||||
* im Browser ununterscheidbar von einem Netzfehler. Ohne dauerhaften Cache
|
||||
* stand an einer längst aufgelösten Stelle plötzlich wieder nur ein
|
||||
* Koordinatenpaar. Auf drei Nachkommastellen gerundet (~110 m), damit ein
|
||||
* paar Meter Abweichung keinen neuen Abruf auslösen.
|
||||
*/
|
||||
/** Ein bereits aufgeloester Ort aus dem Zwischenspeicher - OHNE Netzabruf.
|
||||
*
|
||||
* Die Einzelfahrt loest Koordinaten bei Bedarf auf und legt das Ergebnis im
|
||||
* selben Fach ab wie die Standortansicht. Was einmal dort steht, kann die
|
||||
* Liste umsonst mitbenutzen: kein zusaetzlicher Abruf, keine Wartezeit, kein
|
||||
* Flackern beim Zeichnen.
|
||||
*
|
||||
* Bewusst NUR aus dem Speicher. Ein Abruf je Zeile waere genau der
|
||||
* Vorratsabruf, um dessen Unterlassung Nominatim in seinen
|
||||
* Nutzungsbedingungen bittet - bei zweihundert Fahrten waeren das
|
||||
* vierhundert Anfragen fuer einen Blick auf die Liste.
|
||||
*
|
||||
* Schluessel wie bei der Aufloesung: drei Nachkommastellen (~110 m). */
|
||||
export async function koordinatenAufloesen(lat: number, lon: number): Promise<string | null> {
|
||||
const schluessel = `rev:${lat.toFixed(3)},${lon.toFixed(3)}`
|
||||
const cache = cacheLesen()
|
||||
const gemerkt = cache[schluessel]
|
||||
if (typeof gemerkt === "string") return gemerkt
|
||||
|
||||
try {
|
||||
const antwort = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lon}&zoom=18&addressdetails=1`,
|
||||
{ headers: { "Accept-Language": "de" } },
|
||||
)
|
||||
const daten = (await antwort.json()) as {
|
||||
name?: string
|
||||
type?: string
|
||||
address?: Record<string, string>
|
||||
display_name?: string
|
||||
}
|
||||
const a = daten.address ?? {}
|
||||
const strasse = [a["road"], a["house_number"]].filter(Boolean).join(" ")
|
||||
const stadt = (a["city"] ?? a["town"] ?? a["village"] ?? "").trim()
|
||||
const ort = [a["postcode"], stadt].filter(Boolean).join(" ")
|
||||
/* Steht das Auto auf einem benannten Platz — Parkplatz, Parkhaus,
|
||||
Rastplatz, Ladesäule —, sagt dessen Name mehr als die nächstgelegene
|
||||
Hausnummer: "Parkhaus Westentor" statt "Rathausstraße 4". Nominatim
|
||||
liefert ihn im Feld name, zusammen mit der Art der getroffenen Fläche;
|
||||
ohne die Prüfung auf diese Art würde auch jedes beliebige Gebäude, das
|
||||
zufällig einen Namen trägt, die Anschrift verdrängen. Der Ort bleibt
|
||||
angehängt, damit erkennbar ist, wo der Platz liegt. */
|
||||
const platz = PLATZ_ARTEN.has(daten.type ?? "") ? (daten.name ?? "").trim() : ""
|
||||
const adresse = platz
|
||||
? [platz, ort].filter(Boolean).join(", ")
|
||||
: [strasse, ort].filter(Boolean).join(", ") || daten.display_name || null
|
||||
// Nur echte Treffer merken: eine gedrosselte Antwort ist kein "hier gibt
|
||||
// es keine Adresse", die soll beim nächsten Mal erneut versucht werden.
|
||||
if (adresse) adresseMerken(schluessel, adresse)
|
||||
return adresse
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function adresseAufloesen(adresse: string | null | undefined): Promise<Koordinate | null> {
|
||||
const suche = (adresse ?? "").trim()
|
||||
if (!suche) return null
|
||||
const cache = cacheLesen()
|
||||
if (Object.prototype.hasOwnProperty.call(cache, suche)) {
|
||||
const gemerkt = cache[suche]
|
||||
return typeof gemerkt === "object" ? gemerkt : null
|
||||
}
|
||||
|
||||
let treffer: Koordinate | null = null
|
||||
try {
|
||||
const antwort = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=${encodeURIComponent(suche)}`,
|
||||
{ headers: { "Accept-Language": "de" } },
|
||||
)
|
||||
const daten: unknown = await antwort.json()
|
||||
if (Array.isArray(daten) && daten.length > 0) {
|
||||
const erster = daten[0] as { lat?: string; lon?: string }
|
||||
const lat = Number.parseFloat(erster.lat ?? "")
|
||||
const lon = Number.parseFloat(erster.lon ?? "")
|
||||
if (Number.isFinite(lat) && Number.isFinite(lon)) treffer = { lat, lon }
|
||||
}
|
||||
} catch {
|
||||
// Netzfehler: nichts merken, damit der nächste Aufruf es erneut versucht.
|
||||
return null
|
||||
}
|
||||
cacheSchreiben(suche, treffer)
|
||||
return treffer
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Die Auswertung eines geteilten Belegs — der Teil, der ohne Gerät prüfbar
|
||||
* ist. Das Ablegen selbst passiert nativ (ShareViewController.swift) und kann
|
||||
* hier nicht nachgestellt werden; geprüft wird, was die App mit dem Aufruf und
|
||||
* der abgelegten Datei macht.
|
||||
*
|
||||
* `@capacitor/core` und `@capacitor/filesystem` werden ersetzt: das eine
|
||||
* meldet sonst „kein natives Gerät" und das Lesen steigt sofort aus, das
|
||||
* andere greift auf einen Dateibereich zu, den es hier nicht gibt.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const dateien = new Map<string, string>()
|
||||
|
||||
vi.mock("@capacitor/core", () => ({
|
||||
Capacitor: { isNativePlatform: () => true },
|
||||
}))
|
||||
|
||||
vi.mock("@capacitor/filesystem", () => ({
|
||||
Filesystem: {
|
||||
readFile: vi.fn(async ({ path }: { path: string }) => {
|
||||
const inhalt = dateien.get(path)
|
||||
if (inhalt === undefined) throw new Error("nicht gefunden")
|
||||
return { data: inhalt }
|
||||
}),
|
||||
deleteFile: vi.fn(async ({ path }: { path: string }) => {
|
||||
dateien.delete(path)
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
/** Ein winziges, gültiges PDF als Base64 — der Inhalt ist hier gleichgültig. */
|
||||
const PDF_BASE64 = btoa("%PDF-1.4\n%%EOF\n")
|
||||
|
||||
const PFAD = "file:///var/mobile/Shared/AppGroup/abc/geteilte-belege/1756832400-beleg.pdf"
|
||||
const AUFRUF = `datametric360://beleg?pfad=${encodeURIComponent(PFAD)}`
|
||||
|
||||
describe("Belegpfad aus dem Aufruf", () => {
|
||||
it("erkennt den Pfad", async () => {
|
||||
const { belegpfadAusUrl } = await import("./geteilterBeleg")
|
||||
expect(belegpfadAusUrl(AUFRUF)).toBe(PFAD)
|
||||
})
|
||||
|
||||
it("lässt fremde Aufrufe in Ruhe", async () => {
|
||||
const { belegpfadAusUrl } = await import("./geteilterBeleg")
|
||||
expect(belegpfadAusUrl("datametric360://einstellungen")).toBeNull()
|
||||
expect(belegpfadAusUrl("https://example.org/?pfad=x")).toBeNull()
|
||||
expect(belegpfadAusUrl(null)).toBeNull()
|
||||
})
|
||||
|
||||
it("verträgt einen Aufruf ohne Pfad", async () => {
|
||||
const { belegpfadAusUrl } = await import("./geteilterBeleg")
|
||||
expect(belegpfadAusUrl("datametric360://beleg")).toBeNull()
|
||||
expect(belegpfadAusUrl("datametric360://beleg?anderes=1")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("geteilten Beleg lesen", () => {
|
||||
beforeEach(() => {
|
||||
dateien.clear()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it("liefert nichts ohne Aufruf", async () => {
|
||||
const { geteiltenBelegLesen } = await import("./geteilterBeleg")
|
||||
expect(await geteiltenBelegLesen(null)).toBeNull()
|
||||
})
|
||||
|
||||
it("liest die Datei und gibt sie als PDF zurück", async () => {
|
||||
dateien.set(PFAD, PDF_BASE64)
|
||||
const { geteiltenBelegLesen } = await import("./geteilterBeleg")
|
||||
const datei = await geteiltenBelegLesen(AUFRUF)
|
||||
expect(datei).not.toBeNull()
|
||||
expect(datei?.type).toBe("application/pdf")
|
||||
expect(datei?.name).toBe("1756832400-beleg.pdf")
|
||||
expect(datei?.size).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
/* Der Grund, warum überhaupt gelöscht wird: sonst käme derselbe Beleg bei
|
||||
jedem Öffnen erneut, und weil der Upload im Backend einen Vorgang anlegt,
|
||||
wären das echte Doppeleinträge. */
|
||||
it("räumt die Datei weg, holt sie also genau einmal", async () => {
|
||||
dateien.set(PFAD, PDF_BASE64)
|
||||
const { geteiltenBelegLesen } = await import("./geteilterBeleg")
|
||||
expect(await geteiltenBelegLesen(AUFRUF)).not.toBeNull()
|
||||
expect(dateien.has(PFAD)).toBe(false)
|
||||
expect(await geteiltenBelegLesen(AUFRUF)).toBeNull()
|
||||
})
|
||||
|
||||
it("verschluckt sich nicht an einer fehlenden Datei", async () => {
|
||||
const { geteiltenBelegLesen } = await import("./geteilterBeleg")
|
||||
expect(await geteiltenBelegLesen(AUFRUF)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Übergabe an die Tanken-Seite", () => {
|
||||
beforeEach(() => vi.resetModules())
|
||||
|
||||
it("meldet jeden Wechsel an die Horcher", async () => {
|
||||
const { geteiltenBelegSetzen, geteiltenBelegStand, geteiltenBelegHorchen } =
|
||||
await import("./geteilterBeleg")
|
||||
let gerufen = 0
|
||||
const abmelden = geteiltenBelegHorchen(() => gerufen++)
|
||||
const datei = new File([new Uint8Array([1])], "beleg.pdf", { type: "application/pdf" })
|
||||
|
||||
geteiltenBelegSetzen(datei)
|
||||
expect(geteiltenBelegStand()).toBe(datei)
|
||||
expect(gerufen).toBe(1)
|
||||
|
||||
geteiltenBelegSetzen(null)
|
||||
expect(geteiltenBelegStand()).toBeNull()
|
||||
expect(gerufen).toBe(2)
|
||||
|
||||
abmelden()
|
||||
geteiltenBelegSetzen(datei)
|
||||
expect(gerufen).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Belege, die über das iOS-Teilen-Blatt hereinkommen.
|
||||
*
|
||||
* Die Gegenseite ist `native/teilen/ShareViewController.swift`: sie schreibt
|
||||
* das geteilte PDF in den Container der App-Gruppe `group.app.datametric360`
|
||||
* und öffnet danach die App mit dem Pfad im Aufruf:
|
||||
*
|
||||
* datametric360://beleg?pfad=file:///…/geteilte-belege/1756832400-beleg.pdf
|
||||
*
|
||||
* Diese Datei liest ihn über `@capacitor/filesystem`, das volle `file://`-Pfade
|
||||
* ausdrücklich unterstützt (README: „Simply leave out the `directory` param to
|
||||
* use a full file path"), und löscht die Datei danach.
|
||||
*
|
||||
* DER ERSTE ENTWURF GING ÜBER USERDEFAULTS UND HAT NIE FUNKTIONIERT
|
||||
* ----------------------------------------------------------------
|
||||
* Er legte das PDF base64-kodiert unter `dm360_geteilter_beleg` in
|
||||
* `UserDefaults(suiteName: gruppe)` ab und holte es hier mit
|
||||
* `@capacitor/preferences` und `configure({ group })` wieder heraus — in der
|
||||
* Annahme, das Plugin lese damit die UserDefaults der Gruppe. Das tut es
|
||||
* nicht. Sein iOS-Code (8.0.1, `ios/Sources/PreferencesPlugin/Preferences.swift`)
|
||||
* liest **immer** `UserDefaults.standard` der App; `group` setzt lediglich ein
|
||||
* Schlüssel-Präfix. Gesucht wurde also
|
||||
* `group.app.datametric360.dm360_geteilter_beleg` im eigenen Bereich der App,
|
||||
* während der Eintrag unter dem blanken Schlüssel im Gruppen-Container lag:
|
||||
* falscher Behälter und falscher Schlüssel.
|
||||
*
|
||||
* Sichtbar war davon nur, dass die App aufging und nichts passierte — gemeldet
|
||||
* am 02.09.2026 („es wird kein Tankvorgang angelegt beim Teilen mit der App").
|
||||
*
|
||||
* ABGEHOLT WIRD GENAU EINMAL
|
||||
* --------------------------
|
||||
* Die Datei wird nach dem Lesen gelöscht. Bliebe sie liegen, käme derselbe
|
||||
* Beleg bei jedem Öffnen erneut — und weil der Upload beim Backend einen
|
||||
* Vorgang anlegt, wären das echte Doppeleinträge.
|
||||
*/
|
||||
|
||||
import { Capacitor } from "@capacitor/core"
|
||||
import { Filesystem } from "@capacitor/filesystem"
|
||||
|
||||
/** Muss zum Schema in ShareViewController.swift passen. */
|
||||
const SCHEMA = "datametric360://beleg"
|
||||
|
||||
function base64ZuDatei(base64: string, name: string): File {
|
||||
const roh = atob(base64)
|
||||
const bytes = new Uint8Array(roh.length)
|
||||
for (let i = 0; i < roh.length; i++) bytes[i] = roh.charCodeAt(i)
|
||||
return new File([bytes], name, { type: "application/pdf" })
|
||||
}
|
||||
|
||||
/**
|
||||
* Zieht den Belegpfad aus einem Aufruf der App. `null`, wenn der Aufruf ein
|
||||
* anderer war (die App kann über dasselbe Schema auch anders geöffnet werden).
|
||||
*/
|
||||
export function belegpfadAusUrl(url: string | null | undefined): string | null {
|
||||
if (!url || !url.startsWith(SCHEMA)) return null
|
||||
try {
|
||||
const frage = url.indexOf("?")
|
||||
if (frage < 0) return null
|
||||
const pfad = new URLSearchParams(url.slice(frage + 1)).get("pfad")
|
||||
return pfad || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest den geteilten Beleg und räumt ihn weg. `null`, wenn der Aufruf keinen
|
||||
* Pfad trug oder die Datei nicht lesbar war.
|
||||
*/
|
||||
export async function geteiltenBelegLesen(url: string | null | undefined): Promise<File | null> {
|
||||
if (!Capacitor.isNativePlatform()) return null
|
||||
const pfad = belegpfadAusUrl(url)
|
||||
if (!pfad) return null
|
||||
|
||||
let base64: string
|
||||
try {
|
||||
const gelesen = await Filesystem.readFile({ path: pfad })
|
||||
base64 = typeof gelesen.data === "string" ? gelesen.data : ""
|
||||
} catch (fehler) {
|
||||
console.warn("Geteilter Beleg: Datei nicht lesbar", fehler)
|
||||
return null
|
||||
}
|
||||
if (!base64) return null
|
||||
|
||||
// Erst wegräumen, dann zurückgeben: schlägt das Hochladen später fehl, ist
|
||||
// die Datei zwar weg - aber ein liegengebliebener Beleg, der bei jedem
|
||||
// Öffnen erneut hochlädt, wäre der schlimmere Fehler.
|
||||
try {
|
||||
await Filesystem.deleteFile({ path: pfad })
|
||||
} catch {
|
||||
// Halb so wild: der Name trägt einen Zeitstempel, ein Rest überschreibt
|
||||
// nichts und wird nie wieder gelesen - die App kommt nur über einen
|
||||
// frischen Aufruf an einen Pfad.
|
||||
}
|
||||
|
||||
const name = decodeURIComponent(pfad.split("/").pop() || "beleg.pdf")
|
||||
return base64ZuDatei(base64, name)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- Übergabe
|
||||
|
||||
Zwischen dem Abholen (App.tsx, beim Start und bei jedem Aufruf) und dem
|
||||
Verarbeiten (Tanken.tsx) liegt ein Bildschirmwechsel. Ein kleiner geteilter
|
||||
Speicher nach dem Muster von theme.ts überbrückt ihn — ein Zustand, der
|
||||
außerhalb des Komponentenbaums lebt und den beide Seiten sehen. */
|
||||
|
||||
let wartend: File | null = null
|
||||
const horcher = new Set<() => void>()
|
||||
|
||||
export function geteiltenBelegSetzen(datei: File | null): void {
|
||||
wartend = datei
|
||||
for (const bei of horcher) bei()
|
||||
}
|
||||
|
||||
export function geteiltenBelegStand(): File | null {
|
||||
return wartend
|
||||
}
|
||||
|
||||
export function geteiltenBelegHorchen(bei: () => void): () => void {
|
||||
horcher.add(bei)
|
||||
return () => {
|
||||
horcher.delete(bei)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { buendelPasst } from "./ota"
|
||||
import type { Buendelangabe } from "../api"
|
||||
|
||||
const buendel = (teil: Partial<Buendelangabe> = {}): Buendelangabe => ({
|
||||
version: "2026.8.24.1",
|
||||
sha256: "a".repeat(64),
|
||||
bytes: 224186,
|
||||
gebaut: "2026-08-24T00:00:00Z",
|
||||
url: "/audi_dashboard_static/app/bundle.zip",
|
||||
...teil,
|
||||
})
|
||||
|
||||
/* buendelPasst() entscheidet, ob der Update-Knopf überhaupt erscheint - ein
|
||||
Fehler hier zeigt entweder einen Knopf, der ins Leere läuft (kein Bündel
|
||||
da), oder verbirgt ein echtes Update dauerhaft. */
|
||||
describe("buendelPasst", () => {
|
||||
it("lehnt ab, wenn kein Bündel vorliegt", () => {
|
||||
expect(buendelPasst(null, "2026.8.23.2")).toBe(false)
|
||||
})
|
||||
|
||||
it("lehnt ab, wenn Bündel- und eigene Version übereinstimmen", () => {
|
||||
expect(buendelPasst(buendel({ version: "2026.8.24.1" }), "2026.8.24.1")).toBe(false)
|
||||
})
|
||||
|
||||
it("akzeptiert bei abweichender Version", () => {
|
||||
expect(buendelPasst(buendel({ version: "2026.8.24.2" }), "2026.8.24.1")).toBe(true)
|
||||
})
|
||||
|
||||
it("lehnt ab, wenn die eigene Version unbekannt ist", () => {
|
||||
// null heißt "diese App weiß nicht, welche Version sie ist" (z. B. im
|
||||
// Dev-Server ohne eingebautes __APP_VERSION__) - dann lässt sich ein
|
||||
// Update nicht sinnvoll anbieten, weil "abweichend" nicht feststellbar ist.
|
||||
expect(buendelPasst(buendel(), null)).toBe(false)
|
||||
})
|
||||
|
||||
it("lehnt ab, wenn dem Bündel Pflichtfelder fehlen", () => {
|
||||
expect(buendelPasst(buendel({ url: "" }), "2026.8.23.2")).toBe(false)
|
||||
expect(buendelPasst(buendel({ sha256: "" }), "2026.8.23.2")).toBe(false)
|
||||
expect(buendelPasst(buendel({ version: "" }), "2026.8.23.2")).toBe(false)
|
||||
})
|
||||
|
||||
/* Der eigentliche Zweck, und bis zum 03.09.2026 nur ein Kommentar: das
|
||||
Bündel muss zu der Fassung gehören, die der Server ausliefert. Ohne
|
||||
diese Prüfung meldete die App "diese Fassung ändert auch Natives",
|
||||
obwohl nur das Bündel nach einem Versionssprung nicht neu gebaut war. */
|
||||
it("akzeptiert, wenn das Bündel die Fassung des Servers ist", () => {
|
||||
expect(buendelPasst(buendel({ version: "2026.9.3.11" }), "2026.9.3.10", "2026.9.3.11")).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it("lehnt ab, wenn das Bündel hinter dem Server zurückliegt", () => {
|
||||
expect(buendelPasst(buendel({ version: "2026.9.3.10" }), "2026.9.3.9", "2026.9.3.11")).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it("prüft ohne bekannte Serverfassung nur gegen die eigene", () => {
|
||||
// Offline oder altes Backend: einen Vergleich, den man nicht anstellen
|
||||
// kann, darf der Knopf nicht ausbaden.
|
||||
expect(buendelPasst(buendel({ version: "2026.9.3.11" }), "2026.9.3.10", null)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
/* Der gemeldete Fall vom 04.09.2026: nach einem Xcode-Lauf lief die App auf
|
||||
2026.9.4.17, die Instanz stand noch auf 2026.9.4.5 - und bot an, sich auf
|
||||
.5 zu "erneuern". Das Bündel passte zur Serverfassung, war also nach der
|
||||
damaligen Prüfung in Ordnung; dass es ein Rückschritt ist, konnte sie nicht
|
||||
sehen. Vorgabe des Eigentümers: erkannt wird eine NEUERE Fassung, sonst
|
||||
keine. */
|
||||
describe("buendelPasst geht nur vorwaerts", () => {
|
||||
it("lehnt den gemeldeten Rueckschritt ab, obwohl das Buendel zum Server passt", () => {
|
||||
expect(
|
||||
buendelPasst(buendel({ version: "2026.9.4.5" }), "2026.9.4.17", "2026.9.4.5"),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it("nimmt dieselbe Lage nach dem Nachziehen der Instanz an", () => {
|
||||
// Sobald die Integration auf .18 steht, geht es wieder vorwaerts.
|
||||
expect(
|
||||
buendelPasst(buendel({ version: "2026.9.4.18" }), "2026.9.4.17", "2026.9.4.18"),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("lehnt einen Rueckschritt auch ohne bekannte Serverfassung ab", () => {
|
||||
expect(buendelPasst(buendel({ version: "2026.9.4.5" }), "2026.9.4.17", null)).toBe(false)
|
||||
})
|
||||
|
||||
it("vergleicht die letzte Stelle als Zahl, nicht zeichenweise", () => {
|
||||
// "9" gegen "10": zeichenweise stuende "10" davor.
|
||||
expect(buendelPasst(buendel({ version: "2026.9.4.10" }), "2026.9.4.9", null)).toBe(true)
|
||||
expect(buendelPasst(buendel({ version: "2026.9.4.9" }), "2026.9.4.10", null)).toBe(false)
|
||||
})
|
||||
|
||||
it("bleibt beim alten Verhalten, wenn sich die Reihenfolge nicht bestimmen laesst", () => {
|
||||
// Formatwechsel: dann entscheidet weiterhin allein die Serverfassung -
|
||||
// eine Reihenfolge zu erfinden waere schlechter als die alte Regel.
|
||||
expect(buendelPasst(buendel({ version: "fruehling-3" }), "2026.9.4.17", "fruehling-3")).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Oberflächen-Updates ohne Xcode („OTA").
|
||||
*
|
||||
* Die native Hülle trägt ihre Oberfläche fest gebündelt — sie bleibt auf dem
|
||||
* Stand vom Signieren, unbegrenzt. Diese Datei ist der Ausweg: die App lädt
|
||||
* ein neues Bündel von der eigenen Home-Assistant-Instanz und tauscht es aus.
|
||||
*
|
||||
* WOHER DAS BÜNDEL KOMMT
|
||||
* ----------------------
|
||||
* Aus der Integration selbst: `custom_components/audi_dashboard/frontend/app/`
|
||||
* enthält `bundle.zip` und `bundle.json`, ausgeliefert unter
|
||||
* `/audi_dashboard_static/app/`. Damit reist das Bündel bei jedem
|
||||
* HACS-Update mit, und es kann gar nicht zur Integration unpassend sein — es
|
||||
* ist dieselbe Lieferung. Gebaut wird es von
|
||||
* `companion-app/scripts/ota-paket.ps1`.
|
||||
*
|
||||
* Welche Fassung bereitliegt, sagt `sensor.audi_dashboard_app_version` im
|
||||
* Attribut `daten.buendel`. Kein zweiter Abruf, keine zweite Quelle.
|
||||
*
|
||||
* WAS OTA NICHT KANN
|
||||
* ------------------
|
||||
* Nur Weboberfläche — HTML, CSS, JavaScript, Bilder. Alles Native (Plugins,
|
||||
* Capacitor selbst, Berechtigungen, iOS-Einstellungen) braucht weiterhin
|
||||
* Xcode. Deshalb setzt `resetWhenUpdate` in capacitor.config.ts nach einer
|
||||
* frischen Xcode-Installation wieder auf das eingebaute Bündel zurück: sonst
|
||||
* überdeckte ein älteres OTA-Bündel genau die native Änderung, für die man
|
||||
* Xcode gebraucht hat.
|
||||
*
|
||||
* DIE RÜCKFALLEBENE
|
||||
* -----------------
|
||||
* `startklarMelden()` unten muss nach jedem Start laufen. Bleibt die Meldung
|
||||
* aus (weil das neue Bündel gar nicht erst hochkommt), kehrt das Plugin nach
|
||||
* `appReadyTimeout` von selbst zum vorherigen Bündel zurück. Ein kaputtes
|
||||
* Bündel kann das Gerät deshalb nicht dauerhaft unbrauchbar machen — man
|
||||
* landet wieder da, wo man vorher war.
|
||||
*/
|
||||
|
||||
import { Capacitor } from "@capacitor/core"
|
||||
import { CapacitorUpdater } from "@capgo/capacitor-updater"
|
||||
|
||||
import type { Buendelangabe } from "../api"
|
||||
import { versionOrdnung } from "./appVersion"
|
||||
|
||||
/** Läuft die App in der nativen Hülle? Nur dort gibt es etwas auszutauschen —
|
||||
im Browser und im HA-Panel lädt ohnehin jeder Seitenaufruf den aktuellen
|
||||
Stand. */
|
||||
export function otaMoeglich(): boolean {
|
||||
return Capacitor.isNativePlatform()
|
||||
}
|
||||
|
||||
/**
|
||||
* Meldet dem Plugin, dass dieses Bündel hochgekommen ist.
|
||||
*
|
||||
* Ohne diesen Aufruf rollt das Plugin nach kurzer Zeit auf das vorherige
|
||||
* Bündel zurück — was genau richtig ist, wenn ein Update die App zerlegt hat.
|
||||
* Deshalb steht der Aufruf bewusst dort, wo React nachweislich gerendert hat
|
||||
* (App.tsx), und nicht schon in main.tsx: ein Absturz beim ersten Rendern
|
||||
* soll als Fehlschlag gelten und den Rückfall auslösen.
|
||||
*/
|
||||
export async function startklarMelden(): Promise<void> {
|
||||
if (!otaMoeglich()) return
|
||||
try {
|
||||
await CapacitorUpdater.notifyAppReady()
|
||||
} catch (fehler) {
|
||||
// Nur protokollieren: Scheitert die Meldung, greift der Rückfall — das
|
||||
// ist unangenehm, aber sicher. Ein Absturz an dieser Stelle wäre schlimmer.
|
||||
console.warn("OTA: notifyAppReady fehlgeschlagen", fehler)
|
||||
}
|
||||
}
|
||||
|
||||
/** Ist ein anderes Bündel verfügbar als das gerade laufende?
|
||||
Dieselbe Vorsicht wie in versionVergleichen() (appVersion.ts): kennt die
|
||||
App ihre eigene Version nicht (eigene === null — praktisch nur im
|
||||
Dev-Server, ein natives Release baut nie ohne __APP_VERSION__), gilt das
|
||||
als "nicht vergleichbar", nicht als "abweichend". Alles andere böte einen
|
||||
Update-Knopf an, dessen Ziel man mit der laufenden Fassung gar nicht
|
||||
abgleichen konnte. */
|
||||
export function buendelPasst(
|
||||
buendel: Buendelangabe | null,
|
||||
eigene: string | null,
|
||||
serverVersion?: string | null,
|
||||
): boolean {
|
||||
if (!buendel?.version || !buendel.url || !buendel.sha256 || !eigene) return false
|
||||
// Es muss etwas ANDERES sein als das, was gerade läuft - sonst gibt es
|
||||
// nichts zu holen.
|
||||
if (buendel.version === eigene) return false
|
||||
// Und es muss VORWAERTS gehen. Am 04.09.2026 lief die App nach einem
|
||||
// Xcode-Lauf auf 2026.9.4.17, während die Instanz noch 2026.9.4.5
|
||||
// auslieferte - angeboten wurde ein Rückschritt auf .5, der die
|
||||
// Oberfläche still um zwölf Fassungen zurückgedreht hätte. Die
|
||||
// Gleichheitsprüfung konnte das nicht sehen; siehe appVersion.ts.
|
||||
const ordnung = versionOrdnung(buendel.version, eigene)
|
||||
if (ordnung !== null && ordnung <= 0) return false
|
||||
// Und es muss die Fassung sein, die diese Installation ausliefert. Ohne
|
||||
// bekannte Serverfassung bleibt es beim alten Verhalten - ein Vergleich,
|
||||
// den man nicht anstellen kann, darf den Knopf nicht wegnehmen.
|
||||
if (serverVersion == null) return true
|
||||
return buendel.version === serverVersion
|
||||
}
|
||||
|
||||
export class OtaFehler extends Error {}
|
||||
|
||||
/**
|
||||
* Erklärt einen fehlgeschlagenen Download, statt die Meldung des Plugins
|
||||
* durchzureichen.
|
||||
*
|
||||
* „Checksum" heißt: die geladenen Bytes sind nicht die erwarteten. In diesem
|
||||
* Projekt war die Ursache bisher nie ein kaputtes Bündel (Ablage, Auslieferung
|
||||
* und Prüfsumme sind nachweislich deckungsgleich), sondern ein Download, der
|
||||
* mitten in einen Neustart von Home Assistant lief. Das ist keine Sackgasse,
|
||||
* sondern ein Grund, es gleich noch einmal zu versuchen - und genau das soll
|
||||
* dort stehen, wo der Fehler steht.
|
||||
*/
|
||||
function ladefehlerText(fehler: unknown): string {
|
||||
const meldung = fehler instanceof Error ? fehler.message : String(fehler)
|
||||
if (/checksum|prüfsumme|pruefsumme/i.test(meldung)) {
|
||||
return (
|
||||
"Das Update kam unvollständig an. Das passiert, wenn Home Assistant " +
|
||||
"während des Ladens neu startet. Sobald es wieder läuft, noch einmal " +
|
||||
`versuchen. (${meldung})`
|
||||
)
|
||||
}
|
||||
return `Das Update konnte nicht geladen werden (${meldung}).`
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt das Bündel und schaltet darauf um.
|
||||
*
|
||||
* Kehrt im Erfolgsfall **nie** zurück: `set()` zerstört den JavaScript-Kontext
|
||||
* und lädt die App neu. Alles, was danach stünde, liefe nicht mehr — deshalb
|
||||
* steht hier auch keine Erfolgsmeldung. Der Erfolg ist, dass die App neu
|
||||
* startet.
|
||||
*
|
||||
* @param basisUrl Adresse der Home-Assistant-Instanz, wie beim Einrichten
|
||||
* hinterlegt. Die Bündel-URL aus der Entität ist ein Pfad.
|
||||
*/
|
||||
export async function buendelAnwenden(
|
||||
buendel: Buendelangabe,
|
||||
basisUrl: string,
|
||||
): Promise<never> {
|
||||
if (!otaMoeglich()) throw new OtaFehler("Updates gibt es nur in der App auf dem Gerät.")
|
||||
|
||||
// Die Fassung hängt als Abfrageteil an der Adresse.
|
||||
//
|
||||
// Home Assistant liefert /audi_dashboard_static/ ohne Cache-Control, aber
|
||||
// mit ETag und Last-Modified (nachgemessen). Genau dann darf jeder Zwischen-
|
||||
// speicher - der des Geräts wie einer im Weg - die Datei nach eigenem
|
||||
// Ermessen eine Weile behalten. Käme dabei die vorherige Zip zurück, während
|
||||
// die Prüfsumme aus der Entität längst die neue nennt, scheiterte jedes
|
||||
// Update mit einem Prüfsummenfehler, ohne dass irgendetwas kaputt wäre.
|
||||
// Dieselbe Vorsorge trifft das Panel seit jeher für sein eigenes Bündel.
|
||||
const adresse = new URL(buendel.url, basisUrl)
|
||||
adresse.searchParams.set("v", buendel.version)
|
||||
let geladen
|
||||
try {
|
||||
geladen = await CapacitorUpdater.download({
|
||||
url: adresse.href,
|
||||
version: buendel.version,
|
||||
// Das Plugin rechnet auf dem Gerät SHA-256 über die heruntergeladene
|
||||
// Zip und verwirft sie bei Abweichung (calcChecksum in
|
||||
// CapgoUpdater.swift). Ein abgebrochener Download oder eine halb
|
||||
// gespiegelte Datei fällt damit auf, bevor die App sie startet.
|
||||
checksum: buendel.sha256,
|
||||
})
|
||||
} catch (fehler) {
|
||||
throw new OtaFehler(ladefehlerText(fehler))
|
||||
}
|
||||
|
||||
try {
|
||||
await CapacitorUpdater.set({ id: geladen.id })
|
||||
} catch (fehler) {
|
||||
throw new OtaFehler(
|
||||
`Das Update wurde geladen, ließ sich aber nicht starten (${fehler instanceof Error ? fehler.message : String(fehler)}).`,
|
||||
)
|
||||
}
|
||||
// Unerreichbar: set() lädt die App neu. Steht hier, damit der Rückgabetyp
|
||||
// ehrlich bleibt.
|
||||
throw new OtaFehler("Die App hätte an dieser Stelle neu starten müssen.")
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { readFileSync } from "node:fs"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { hauptuntersuchungFaellig } from "./service"
|
||||
|
||||
/* Vergleicht die Ableitung der Hauptuntersuchung Zeile für Zeile gegen die
|
||||
ECHTEN Panel-Funktionen, aus der Panel-Quelle herausgeschnitten. */
|
||||
const quelle = readFileSync(
|
||||
"../custom_components/audi_dashboard/frontend/audi-dashboard-app.js", "utf8")
|
||||
const block = (kopf: string) => {
|
||||
const i = quelle.indexOf(kopf)
|
||||
if (i < 0) throw new Error("fehlt: " + kopf)
|
||||
let tiefe = 0
|
||||
for (let k = quelle.indexOf("{", i); k < quelle.length; k++) {
|
||||
if (quelle[k] === "{") tiefe++
|
||||
else if (quelle[k] === "}" && !--tiefe) return quelle.slice(i, k + 1)
|
||||
}
|
||||
throw new Error("unvollstaendig: " + kopf)
|
||||
}
|
||||
const zeile = (muster: RegExp) => {
|
||||
const m = muster.exec(quelle)
|
||||
if (!m) throw new Error("fehlt: " + muster)
|
||||
return m[0]
|
||||
}
|
||||
|
||||
const panel = new Function(`
|
||||
${zeile(/^const DATUM_ISO = .*$/m)}
|
||||
${zeile(/^const DATUM_TAG = .*$/m)}
|
||||
${zeile(/^const DATUM_MONAT = .*$/m)}
|
||||
${zeile(/^const DATUM_ISO_MONAT = .*$/m)}
|
||||
${block("function datumBauen")}
|
||||
${zeile(/^const gueltigesDatum = .*$/m)}
|
||||
${block("function datumAusText")}
|
||||
${block("function dat(")}
|
||||
${zeile(/^function monatePlus.*$/m)}
|
||||
return { datumAusText, monatePlus };
|
||||
`)() as {
|
||||
datumAusText: (w: string) => { zeit: Date; genau: string } | null
|
||||
monatePlus: (d: string | Date, m: number) => Date
|
||||
}
|
||||
|
||||
const iso = (d: Date | null) => d === null ? null
|
||||
: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
|
||||
|
||||
/* Wie hauptuntersuchungsTermin() im Panel, nur ohne dessen CAR-Zugriff.
|
||||
|
||||
Beide Seiten führen die Genauigkeit mit, nur in verschiedener Form: das
|
||||
Panel als Merkmal `genau` am Termin, die App als ISO-Monat im Text
|
||||
("2028-03"). Verglichen wird deshalb die normalisierte Form - dieselbe
|
||||
Aussage, nicht dieselbe Darstellung. */
|
||||
const panelHU = (erst: string, eintragDe?: string) => {
|
||||
if (eintragDe) return iso(panel.monatePlus(eintragDe, 24))
|
||||
const z = panel.datumAusText(erst)
|
||||
if (!z) return null
|
||||
const ziel = iso(panel.monatePlus(z.zeit, 36))
|
||||
return z.genau === "monat" ? ziel!.slice(0, 7) : ziel
|
||||
}
|
||||
|
||||
describe("Hauptuntersuchung: Panel und App rechnen gleich", () => {
|
||||
for (const erst of ["01.03.2025", "2025-03-01", "15.01.2025", "20.11.2025",
|
||||
"10.02.2025", "29.02.2024", "31.12.2023", "03/2025",
|
||||
"31.02.2025", "", "Unsinn"]) {
|
||||
it(`Erstzulassung ${JSON.stringify(erst)}`, () => {
|
||||
expect(hauptuntersuchungFaellig([], erst)).toBe(panelHU(erst))
|
||||
})
|
||||
}
|
||||
const paare: [string, string][] = [["15.01.2025", "2025-01-15"], ["20.11.2025", "2025-11-20"],
|
||||
["10.02.2025", "2025-02-10"], ["29.02.2024", "2024-02-29"]]
|
||||
for (const [de, isoDatum] of paare) {
|
||||
it(`Wartungsplan ${de}`, () => {
|
||||
expect(hauptuntersuchungFaellig([{ art: "Hauptuntersuchung", datum: isoDatum }], "01.03.2025"))
|
||||
.toBe(panelHU("01.03.2025", de))
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { readFileSync } from "node:fs"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { terminUeberschritten } from "./service"
|
||||
|
||||
/* Beide Codebasen müssen denselben Termin gleich beurteilen - sonst steht auf
|
||||
der Übersicht "überschritten" und auf derselben Seite im Panel nicht. Geprüft
|
||||
wird gegen die ECHTE Panel-Funktion, aus der Panel-Quelle herausgeschnitten,
|
||||
nicht gegen einen Nachbau. Muster wie in paritaet_hu.test.ts. */
|
||||
const quelle = readFileSync(
|
||||
"../custom_components/audi_dashboard/frontend/audi-dashboard-app.js", "utf8")
|
||||
const block = (kopf: string) => {
|
||||
const i = quelle.indexOf(kopf)
|
||||
if (i < 0) throw new Error("fehlt: " + kopf)
|
||||
let tiefe = 0
|
||||
for (let k = quelle.indexOf("{", i); k < quelle.length; k++) {
|
||||
if (quelle[k] === "{") tiefe++
|
||||
else if (quelle[k] === "}" && !--tiefe) return quelle.slice(i, k + 1)
|
||||
}
|
||||
throw new Error("unvollstaendig: " + kopf)
|
||||
}
|
||||
const zeile = (muster: RegExp) => {
|
||||
const m = muster.exec(quelle)
|
||||
if (!m) throw new Error("fehlt: " + muster)
|
||||
return m[0]
|
||||
}
|
||||
|
||||
const panel = new Function(`
|
||||
${zeile(/^const DATUM_ISO = .*$/m)}
|
||||
${zeile(/^const DATUM_TAG = .*$/m)}
|
||||
${zeile(/^const DATUM_MONAT = .*$/m)}
|
||||
${zeile(/^const DATUM_ISO_MONAT = .*$/m)}
|
||||
${zeile(/^const DATUM_ZEITSTEMPEL = .*$/m)}
|
||||
${zeile(/^const gueltigesDatum = .*$/m)}
|
||||
${block("function datumBauen")}
|
||||
${block("function datumAusText")}
|
||||
${block("function zeitstempelAlsDatum")}
|
||||
${block("function terminUeberschritten")}
|
||||
return { terminUeberschritten };
|
||||
`)() as { terminUeberschritten: (...k: (string | Date | null)[]) => boolean }
|
||||
|
||||
const tag = (versatz: number) => {
|
||||
const x = new Date()
|
||||
x.setDate(x.getDate() + versatz)
|
||||
return `${String(x.getDate()).padStart(2, "0")}.${String(x.getMonth() + 1).padStart(2, "0")}.${x.getFullYear()}`
|
||||
}
|
||||
|
||||
describe("terminUeberschritten: Panel und App urteilen gleich", () => {
|
||||
const faelle: { name: string; args: (string | null)[] }[] = [
|
||||
{ name: "gestern", args: [tag(-1)] },
|
||||
{ name: "heute", args: [tag(0)] },
|
||||
{ name: "morgen", args: [tag(1)] },
|
||||
{ name: "ISO vorbei", args: ["2024-03-01"] },
|
||||
{ name: "ISO Zukunft", args: ["2028-07-23"] },
|
||||
{ name: "Fahrzeug-Zeitstempel vorbei", args: ["2025-10-21T00:00:00+00:00"] },
|
||||
{ name: "Fahrzeug-Zeitstempel Zukunft", args: ["2027-10-21T00:00:00+00:00"] },
|
||||
{ name: "monatsgenau vorbei", args: ["03/2025"] },
|
||||
{ name: "monatsgenau Zukunft", args: ["03/2099"] },
|
||||
{ name: "kein Datum", args: [null] },
|
||||
{ name: "leer", args: [""] },
|
||||
{ name: "unlesbar", args: ["irgendwas"] },
|
||||
{ name: "erstes lesbares: null, dann gestern", args: [null, tag(-1)] },
|
||||
{ name: "erstes lesbares: morgen vor gestern", args: [tag(1), tag(-1)] },
|
||||
]
|
||||
|
||||
for (const f of faelle) {
|
||||
it(f.name, () => {
|
||||
expect(terminUeberschritten(...f.args)).toBe(panel.terminUeberschritten(...f.args))
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -19,12 +19,10 @@ function beispielprofil(aenderung: Partial<Profil> = {}): Profil {
|
||||
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 },
|
||||
smartdeal: { aktiv: true, laeuft_ab: null },
|
||||
oelwechsel_intervall: { modus: "hersteller", hersteller_km: 30000, hersteller_monate: 24 },
|
||||
},
|
||||
reifen: {
|
||||
@@ -43,13 +41,11 @@ describe("profilZuEinstellungen", () => {
|
||||
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")
|
||||
})
|
||||
})
|
||||
@@ -95,7 +91,7 @@ describe("profilZuFahrzeug mit Fahrzeugstatus", () => {
|
||||
tankprozent: 62,
|
||||
reichweite_km: 385,
|
||||
gesichert: true,
|
||||
sicherheitscheck: [{ label: "Tür vorne links", ok: true }],
|
||||
sicherheitscheck: [{ label: "Tür vorne links", ok: true, gruppe: "tueren_klappen" }],
|
||||
})
|
||||
expect(f.odo).toBe(48250)
|
||||
expect(f.odoBekannt).toBe(true)
|
||||
|
||||
@@ -19,12 +19,13 @@ export interface Einstellungen {
|
||||
tankvolumen: number
|
||||
nachtVon: string
|
||||
nachtBis: string
|
||||
pauseMin: number
|
||||
markenname: string
|
||||
wlanName: string
|
||||
backupIntervall: string
|
||||
letztesBackup: string | null
|
||||
ausfuehrung: string
|
||||
/** Minuten nach dem Zuendungs-Aus, bis die Fahrt automatisch endet.
|
||||
0 heisst: sofort. Siehe fahrterkennung._pausenzeit_s im Backend. */
|
||||
pausenzeitMin: number
|
||||
}
|
||||
|
||||
export interface Reifensatz {
|
||||
@@ -37,18 +38,49 @@ export interface Reifensatz {
|
||||
[weitere: string]: unknown
|
||||
}
|
||||
|
||||
/** Ein per "Neue Räder anlegen" archivierter Reifensatz (reifen.py
|
||||
archivieren()) - der vollständige Stand zum Zeitpunkt des Austauschs. */
|
||||
export interface ReifenArchivEintrag {
|
||||
id: string
|
||||
satz: "sommer" | "winter"
|
||||
ersetzt_am: string
|
||||
km: number | null
|
||||
marke: string | null
|
||||
modell: string | null
|
||||
dot: string | null
|
||||
mass: string | null
|
||||
druck_vorne: string | null
|
||||
druck_hinten: string | null
|
||||
kommentar: string | null
|
||||
}
|
||||
|
||||
export interface Fahrzeug {
|
||||
fin: string
|
||||
details: string
|
||||
erstzulassung: string
|
||||
hu: string
|
||||
odo: number
|
||||
odoBekannt: boolean
|
||||
tankPct: number
|
||||
tankBekannt: boolean
|
||||
reichweite: number | null
|
||||
batteriespannung: number | null
|
||||
gesichert: boolean | null
|
||||
sicherheitscheck: SicherheitsPunkt[]
|
||||
/** Diagnosecodes im Fehlerspeicher; `null` heisst "kein Sensor zugeordnet
|
||||
oder er meldet nichts" - nie als "keine Fehler" deuten. */
|
||||
fehlerspeicher: { anzahl: number | null; ok: boolean | null } | null
|
||||
/** Genauigkeit der Position in Metern; `null` heisst unbekannt. */
|
||||
standortGenauigkeitM: number | null
|
||||
/** Zustand des Zündungssensors — nicht „fährt": ohne Bewegungssensor sagt
|
||||
an nur, dass die Zündung an ist. `null` heißt „kein Zündungssensor
|
||||
zugeordnet", also unbekannt — nicht „steht". */
|
||||
zuendung: boolean | null
|
||||
geparktSeit: string | null
|
||||
/** Beginn der laufenden Fahrt, sonst null - siehe faehrt_seit im Backend. */
|
||||
faehrtSeit: string | null
|
||||
standortLat: number | null
|
||||
standortLon: number | null
|
||||
standortZeit: string | null
|
||||
oelwechselFaelligTs: string | null
|
||||
oelwechselFaelligKm: number | null
|
||||
inspektionFaelligTs: string | null
|
||||
@@ -69,6 +101,8 @@ export interface Fahrzeug {
|
||||
wechsel: Record<string, unknown>
|
||||
sommer: Reifensatz
|
||||
winter: Reifensatz
|
||||
/** Neueste zuerst - schon so im Profil (reifen.py archivieren()). */
|
||||
archiv: ReifenArchivEintrag[]
|
||||
}
|
||||
service: Record<string, unknown>
|
||||
technik: Record<string, unknown>
|
||||
@@ -108,16 +142,22 @@ export function profilZuEinstellungen(profil: Profil): Einstellungen {
|
||||
fahrzeugzusatz: text(fahrzeug, "zusatz"),
|
||||
fahrzeuguntertitel: text(fahrzeug, "untertitel"),
|
||||
kennzeichen: text(fahrzeug, "kennzeichen"),
|
||||
startbild: text(einst, "uebersichtsbild", "seitenansicht.webp"),
|
||||
// Der Name der Ansicht, nicht der Dateiname - so speichert es das Panel.
|
||||
startbild: text(einst, "uebersichtsbild", "Seitenansicht"),
|
||||
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"),
|
||||
// Der Wert hat die Zeit ueberlebt, in der die Einstellung nicht angeboten
|
||||
// wurde (31.08. bis 03.09.2026) - deshalb steht er in vielen Profilen
|
||||
// schon, und der Standard greift nur bei neuen.
|
||||
// Auf den Bereich des Reglers geklemmt - siehe profilZuConfig() im Panel:
|
||||
// vor dem 03.09.2026 war 0 ("sofort") moeglich, das Geraet kennt aber kein
|
||||
// Schlaf-Timeout unter einer Minute.
|
||||
pausenzeitMin: Math.min(60, Math.max(1, zahl(einst, "fahrten_pausenzeit_min") ?? 15)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,14 +178,22 @@ export function profilZuFahrzeug(profil: Profil, status: Fahrzeugstatus): Fahrze
|
||||
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,
|
||||
tankBekannt: status.tankprozent !== null && status.tankprozent !== undefined,
|
||||
reichweite: status.reichweite_km ?? null,
|
||||
batteriespannung: status.batteriespannung ?? null,
|
||||
gesichert: status.gesichert ?? null,
|
||||
sicherheitscheck: status.sicherheitscheck ?? [],
|
||||
fehlerspeicher: status.fehlerspeicher ?? null,
|
||||
standortGenauigkeitM: status.standort_genauigkeit_m ?? null,
|
||||
zuendung: status.zuendung ?? null,
|
||||
geparktSeit: (status.geparkt_seit as string | null) ?? null,
|
||||
faehrtSeit: (status.faehrt_seit as string | null) ?? null,
|
||||
standortLat: status.standort_lat ?? null,
|
||||
standortLon: status.standort_lon ?? null,
|
||||
standortZeit: status.standort_zeit ?? null,
|
||||
oelwechselFaelligTs: status.oelwechsel_faellig_ts ?? null,
|
||||
oelwechselFaelligKm: status.oelwechsel_faellig_km ?? null,
|
||||
inspektionFaelligTs: status.inspektion_faellig_ts ?? null,
|
||||
@@ -171,6 +219,7 @@ export function profilZuFahrzeug(profil: Profil, status: Fahrzeugstatus): Fahrze
|
||||
wechsel: (reifen["wechsel"] ?? {}) as Record<string, unknown>,
|
||||
sommer: saetze["sommer"] ?? {},
|
||||
winter: saetze["winter"] ?? {},
|
||||
archiv: (reifen["archiv"] as ReifenArchivEintrag[] | undefined) ?? [],
|
||||
},
|
||||
service: abschnitt(profil, "service"),
|
||||
technik: abschnitt(profil, "technik"),
|
||||
@@ -199,18 +248,16 @@ export function zusammenfuehren(
|
||||
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["fahrten_pausenzeit_min"] = einstellungen.pausenzeitMin
|
||||
e["smartdeal"] = { aktiv: fahrzeug.smartdeal.aktiv, laeuft_ab: fahrzeug.smartdeal.laeuftAb }
|
||||
e["oelwechsel_intervall"] = {
|
||||
modus: fahrzeug.oel.modus,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { letzterOelwechsel, oelwechselPrognose } from "./service"
|
||||
import { hauptuntersuchungFaellig, inspektionPrognose, kmProTagAusFahrten, letzterOelwechsel, meldungsPrognose, meldungsPrognoseEigenesIntervall, naechsterService, oelMeldungsPrognose, oelwechselPrognose, terminUeberschritten } from "./service"
|
||||
import type { Fahrzeug } from "./profilAdapter"
|
||||
|
||||
function fahrzeug(odo: number, modus = "hersteller"): Fahrzeug {
|
||||
@@ -8,14 +8,22 @@ function fahrzeug(odo: number, modus = "hersteller"): Fahrzeug {
|
||||
fin: "",
|
||||
details: "",
|
||||
erstzulassung: "",
|
||||
hu: "",
|
||||
odo,
|
||||
odoBekannt: true,
|
||||
tankPct: 0,
|
||||
tankBekannt: true,
|
||||
reichweite: null,
|
||||
batteriespannung: null,
|
||||
gesichert: null,
|
||||
sicherheitscheck: [],
|
||||
fehlerspeicher: null,
|
||||
standortGenauigkeitM: null,
|
||||
zuendung: null,
|
||||
geparktSeit: null,
|
||||
faehrtSeit: null,
|
||||
standortLat: null,
|
||||
standortLon: null,
|
||||
standortZeit: null,
|
||||
oelwechselFaelligTs: null,
|
||||
oelwechselFaelligKm: null,
|
||||
inspektionFaelligTs: null,
|
||||
@@ -24,7 +32,7 @@ function fahrzeug(odo: number, modus = "hersteller"): Fahrzeug {
|
||||
oel: { modus, km: 15000, monate: 12, herstellerKm: 30000, herstellerMonate: 24 },
|
||||
versicherung: {},
|
||||
steuer: { faellig: "" },
|
||||
reifen: { aktiv: "Sommer", anzugsmoment: null, wechsel: {}, sommer: {}, winter: {} },
|
||||
reifen: { aktiv: "Sommer", anzugsmoment: null, wechsel: {}, sommer: {}, winter: {}, archiv: [] },
|
||||
service: {},
|
||||
technik: {},
|
||||
ausstattung: [],
|
||||
@@ -92,3 +100,340 @@ describe("oelwechselPrognose", () => {
|
||||
expect(oelwechselPrognose(fahrzeug(46000), [], jetzt)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("inspektionPrognose", () => {
|
||||
const jetzt = new Date("2026-08-11T12:00:00")
|
||||
|
||||
it("rechnet mit dem festen Intervall 30.000 km / 24 Monate, nicht dem Ölwechsel-Intervall", () => {
|
||||
// Letzte Inspektion 05.03.2025 bei 25000 km -> Ziel 55000. Bei 46000 km
|
||||
// sind das 9000 km offen, unabhängig vom oel-Feld des Fahrzeugs (15000/12).
|
||||
const p = inspektionPrognose(fahrzeug(46000), BUCH, jetzt)
|
||||
expect(p).not.toBeNull()
|
||||
expect(p!.restKm).toBe(9000)
|
||||
})
|
||||
|
||||
it("ignoriert den Ölwechsel-Eintrag bei der Suche nach der letzten Inspektion", () => {
|
||||
expect(inspektionPrognose(fahrzeug(46000), [BUCH[0]!], jetzt)).toBeNull()
|
||||
})
|
||||
|
||||
it("liefert nichts, wenn nie eine Inspektion eingetragen wurde", () => {
|
||||
expect(inspektionPrognose(fahrzeug(46000), [], jetzt)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("naechsterService", () => {
|
||||
const jetzt = new Date("2026-08-11T12:00:00")
|
||||
|
||||
it("wählt über Ölwechsel und Inspektion hinweg das zeitlich frühere Datum", () => {
|
||||
// Beide unabhängig mit derselben Formel berechnet, statt ein erwartetes
|
||||
// Datum von Hand vorzurechnen - so bleibt der Test robust gegen spätere
|
||||
// Änderungen an den Intervallen, prüft aber trotzdem die Auswahllogik.
|
||||
const oel = oelwechselPrognose(fahrzeug(46000), BUCH, jetzt)
|
||||
const insp = inspektionPrognose(fahrzeug(46000), BUCH, jetzt)
|
||||
const erwarteteArt = oel!.datum <= insp!.datum ? "Ölwechsel" : "Inspektion"
|
||||
|
||||
const s = naechsterService(fahrzeug(46000), BUCH, [], jetzt)
|
||||
expect(s).not.toBeNull()
|
||||
expect(s!.art).toBe(erwarteteArt)
|
||||
expect(s!.datum).toBe(erwarteteArt === "Ölwechsel" ? oel!.datum : insp!.datum)
|
||||
})
|
||||
|
||||
it("nimmt die Hauptuntersuchung auf, obwohl sie keine eigene Prognose hat", () => {
|
||||
const f = { ...fahrzeug(46000), erstzulassung: "01.03.2025" }
|
||||
const s = naechsterService(f, [], [], jetzt)
|
||||
expect(s).not.toBeNull()
|
||||
expect(s!.art).toBe("Hauptuntersuchung")
|
||||
expect(s!.restKm).toBeNull()
|
||||
expect(s!.datum).toBe("2028-03-01")
|
||||
})
|
||||
|
||||
it("liefert nichts ohne Servicebuch und ohne Erstzulassung", () => {
|
||||
expect(naechsterService(fahrzeug(46000), [], [], jetzt)).toBeNull()
|
||||
})
|
||||
|
||||
it("fällt ohne Servicebucheintrag auf die vom Fahrzeug gemeldete Ölwechsel-Fälligkeit zurück", () => {
|
||||
// Ein frisch eingerichtetes Fahrzeug mit zugeordnetem Sensor, aber noch
|
||||
// ganz ohne Servicebuch, muss die Meldung trotzdem zeigen - siehe
|
||||
// AGENTS.md, Abschnitt H, "no inspektion and ölwechsel shown ...".
|
||||
const f = { ...fahrzeug(46000), oelwechselFaelligTs: "2027-03-14T00:00:00+00:00", oelwechselFaelligKm: 11750 }
|
||||
const s = naechsterService(f, [], [], jetzt)
|
||||
expect(s).not.toBeNull()
|
||||
expect(s!.art).toBe("Ölwechsel")
|
||||
expect(s!.datum).toBe("2027-03-14")
|
||||
expect(s!.restKm).toBe(11750)
|
||||
})
|
||||
|
||||
it("fällt ohne Servicebucheintrag auf die vom Fahrzeug gemeldete Inspektions-Fälligkeit zurück", () => {
|
||||
const f = { ...fahrzeug(46000), inspektionFaelligTs: "2027-01-01T00:00:00+00:00", inspektionFaelligKm: 5000 }
|
||||
const s = naechsterService(f, [], [], jetzt)
|
||||
expect(s).not.toBeNull()
|
||||
expect(s!.art).toBe("Inspektion")
|
||||
expect(s!.datum).toBe("2027-01-01")
|
||||
expect(s!.restKm).toBe(5000)
|
||||
})
|
||||
|
||||
it("bevorzugt die eigene Prognose gegenüber der Fahrzeugmeldung, wenn beide vorliegen", () => {
|
||||
// Servicebucheintrag vorhanden -> oelwechselPrognose() greift; die
|
||||
// Meldung (weit in der Zukunft) darf sie nicht verdrängen.
|
||||
const f = { ...fahrzeug(46000), oelwechselFaelligTs: "2099-01-01T00:00:00+00:00", oelwechselFaelligKm: 99999 }
|
||||
const oel = oelwechselPrognose(f, BUCH, jetzt)
|
||||
expect(oel).not.toBeNull()
|
||||
const s = naechsterService(f, BUCH, [], jetzt)
|
||||
expect(s!.datum).not.toBe("2099-01-01")
|
||||
})
|
||||
})
|
||||
|
||||
describe("kmProTagAusFahrten", () => {
|
||||
const jetzt = new Date("2026-08-11T12:00:00")
|
||||
|
||||
it("rechnet aus abgeschlossenen Fahrten seit der ältesten die km/Tag-Rate", () => {
|
||||
// Exakt 30 Tage vor jetzt (gleiche Uhrzeit), 900 km insgesamt -> 30 km/Tag.
|
||||
const fahrten = [
|
||||
{ ts_start: "2026-07-12T12:00:00", distance_km: 400, status: "abgeschlossen" },
|
||||
{ ts_start: "2026-08-01T08:00:00", distance_km: 500, status: "abgeschlossen" },
|
||||
]
|
||||
expect(kmProTagAusFahrten(fahrten, jetzt)).toBeCloseTo(30, 5)
|
||||
})
|
||||
|
||||
it("ignoriert offene Fahrten und Fahrten ohne bekannte Distanz", () => {
|
||||
const fahrten = [
|
||||
{ ts_start: "2026-07-12T08:00:00", distance_km: 400, status: "abgeschlossen" },
|
||||
{ ts_start: "2026-08-10T08:00:00", distance_km: null, status: "abgeschlossen" },
|
||||
{ ts_start: "2026-08-11T08:00:00", distance_km: 9999, status: "offen" },
|
||||
]
|
||||
// Nur die erste Fahrt zählt -> dieselbe Rate wie ohne die beiden anderen.
|
||||
expect(kmProTagAusFahrten(fahrten, jetzt)).toBe(
|
||||
kmProTagAusFahrten([fahrten[0]!], jetzt),
|
||||
)
|
||||
})
|
||||
|
||||
it("liefert nichts ohne Fahrten oder ohne belastbaren Zeitraum", () => {
|
||||
expect(kmProTagAusFahrten([], jetzt)).toBeNull()
|
||||
expect(kmProTagAusFahrten([{ ts_start: jetzt.toISOString(), distance_km: 5, status: "abgeschlossen" }], jetzt))
|
||||
.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("meldungsPrognose", () => {
|
||||
const jetzt = new Date("2026-08-11T12:00:00")
|
||||
// 30 km/Tag, siehe kmProTagAusFahrten-Tests oben.
|
||||
const fahrten = [
|
||||
{ ts_start: "2026-07-12T12:00:00", distance_km: 400, status: "abgeschlossen" },
|
||||
{ ts_start: "2026-08-01T08:00:00", distance_km: 500, status: "abgeschlossen" },
|
||||
]
|
||||
|
||||
it("rechnet die Restkilometer mit der Fahrleistung aus dem Fahrtenlog auf ein Datum hoch", () => {
|
||||
// 300 km Rest bei 30 km/Tag -> 10 Tage.
|
||||
const p = meldungsPrognose("2099-01-01T00:00:00+00:00", 300, fahrten, jetzt)
|
||||
expect(p).not.toBeNull()
|
||||
expect(p!.datum).toBe("2026-08-21")
|
||||
expect(p!.durchZeitlimit).toBe(false)
|
||||
})
|
||||
|
||||
it("deckelt auf die vom Fahrzeug gemeldete Zeitgrenze, wenn zu wenig gefahren wird", () => {
|
||||
// 30000 km Rest bei 30 km/Tag läge Jahre in der Zukunft - die gemeldete
|
||||
// Zeitgrenze (fünf Tage später) greift zuerst.
|
||||
const p = meldungsPrognose("2026-08-16T00:00:00+00:00", 30000, fahrten, jetzt)
|
||||
expect(p).not.toBeNull()
|
||||
expect(p!.datum).toBe("2026-08-16")
|
||||
expect(p!.durchZeitlimit).toBe(true)
|
||||
})
|
||||
|
||||
it("fällt ohne Fahrtenlog auf die reine Zeitgrenze der Meldung zurück", () => {
|
||||
const p = meldungsPrognose("2027-01-01T00:00:00+00:00", 5000, [], jetzt)
|
||||
expect(p).not.toBeNull()
|
||||
expect(p!.datum).toBe("2027-01-01")
|
||||
expect(p!.durchZeitlimit).toBe(true)
|
||||
})
|
||||
|
||||
it("liefert nichts ohne jede brauchbare Angabe", () => {
|
||||
expect(meldungsPrognose(null, null, fahrten, jetzt)).toBeNull()
|
||||
})
|
||||
|
||||
it("gibt die Restkilometer mit zurück", () => {
|
||||
const p = meldungsPrognose("2027-01-01T00:00:00+00:00", 5000, [], jetzt)
|
||||
expect(p!.restKm).toBe(5000)
|
||||
})
|
||||
})
|
||||
|
||||
describe("meldungsPrognoseEigenesIntervall", () => {
|
||||
const jetzt = new Date("2026-08-11T12:00:00")
|
||||
// 30 km/Tag, siehe kmProTagAusFahrten-Tests oben.
|
||||
const fahrten = [
|
||||
{ ts_start: "2026-07-12T12:00:00", distance_km: 400, status: "abgeschlossen" },
|
||||
{ ts_start: "2026-08-01T08:00:00", distance_km: 500, status: "abgeschlossen" },
|
||||
]
|
||||
|
||||
it("rechnet Restkilometer und Zeitgrenze auf das eigene, kürzere Intervall um", () => {
|
||||
// Herstellermeldung: 25.000 km / 24 Monate Rest bis zum 2027-08-11 -
|
||||
// daraus ergibt sich ein angenommener letzter Service vor 2025-08-11.
|
||||
// Eigenes Intervall (15.000 km / 12 Monate) ab demselben Punkt: Zeit-
|
||||
// grenze 2026-08-11 (heute), Restkilometer 25000 - 30000 + 15000 = 10000.
|
||||
const p = meldungsPrognoseEigenesIntervall(
|
||||
"2027-08-11T00:00:00+00:00", 25000, 30000, 24, 15000, 12, fahrten, jetzt,
|
||||
)
|
||||
expect(p).not.toBeNull()
|
||||
expect(p!.restKm).toBe(10000)
|
||||
// 10.000 km bei 30 km/Tag läge weit in der Zukunft - die zurückgerechnete
|
||||
// eigene Zeitgrenze (heute) greift zuerst.
|
||||
expect(p!.durchZeitlimit).toBe(true)
|
||||
expect(p!.datum).toBe("2026-08-11")
|
||||
})
|
||||
|
||||
it("deckelt die Restkilometer bei null, wenn das eigene Intervall rechnerisch schon überschritten ist", () => {
|
||||
// Erst 10.000 von 30.000 km seit der Herstellerfälligkeit gefahren -
|
||||
// das kürzere eigene Intervall (15.000 km) ist rechnerisch längst fällig.
|
||||
const p = meldungsPrognoseEigenesIntervall(
|
||||
"2027-08-11T00:00:00+00:00", 10000, 30000, 24, 15000, 12, fahrten, jetzt,
|
||||
)
|
||||
expect(p).not.toBeNull()
|
||||
expect(p!.restKm).toBe(0)
|
||||
expect(p!.durchZeitlimit).toBe(false)
|
||||
})
|
||||
|
||||
it("fällt ohne Fahrtenlog auf die zurückgerechnete eigene Zeitgrenze zurück", () => {
|
||||
const p = meldungsPrognoseEigenesIntervall(
|
||||
"2027-08-11T00:00:00+00:00", 25000, 30000, 24, 15000, 12, [], jetzt,
|
||||
)
|
||||
expect(p).not.toBeNull()
|
||||
expect(p!.datum).toBe("2026-08-11")
|
||||
expect(p!.durchZeitlimit).toBe(true)
|
||||
})
|
||||
|
||||
it("liefert nichts ohne bekannte Herstellerintervall-Länge", () => {
|
||||
expect(
|
||||
meldungsPrognoseEigenesIntervall("2027-08-11T00:00:00+00:00", 25000, null, 24, 15000, 12, fahrten, jetzt),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("oelMeldungsPrognose", () => {
|
||||
const jetzt = new Date("2026-08-11T12:00:00")
|
||||
const fahrten = [
|
||||
{ ts_start: "2026-07-12T12:00:00", distance_km: 400, status: "abgeschlossen" },
|
||||
{ ts_start: "2026-08-01T08:00:00", distance_km: 500, status: "abgeschlossen" },
|
||||
]
|
||||
|
||||
it("übernimmt bei Herstellervorgabe unverändert die reine Fahrzeugmeldung", () => {
|
||||
const f = fahrzeug(10000, "hersteller")
|
||||
f.oelwechselFaelligTs = "2027-08-11T00:00:00+00:00"
|
||||
f.oelwechselFaelligKm = 25000
|
||||
const p = oelMeldungsPrognose(f, fahrten, jetzt)
|
||||
expect(p!.restKm).toBe(25000)
|
||||
expect(p!.datum).toBe("2027-08-11")
|
||||
})
|
||||
|
||||
it("rechnet bei eigenem Intervall auf dessen kürzere Vorgabe um", () => {
|
||||
const f = fahrzeug(10000, "eigen")
|
||||
f.oelwechselFaelligTs = "2027-08-11T00:00:00+00:00"
|
||||
f.oelwechselFaelligKm = 25000
|
||||
const p = oelMeldungsPrognose(f, fahrten, jetzt)
|
||||
expect(p!.restKm).toBe(10000)
|
||||
expect(p!.datum).toBe("2026-08-11")
|
||||
})
|
||||
|
||||
it("fällt bei eigenem Intervall ohne Herstellerwerte auf die reine Meldung zurück", () => {
|
||||
const f = fahrzeug(10000, "eigen")
|
||||
f.oel.herstellerKm = null
|
||||
f.oelwechselFaelligTs = "2027-08-11T00:00:00+00:00"
|
||||
f.oelwechselFaelligKm = 25000
|
||||
const p = oelMeldungsPrognose(f, fahrten, jetzt)
|
||||
expect(p!.restKm).toBe(25000)
|
||||
expect(p!.datum).toBe("2027-08-11")
|
||||
})
|
||||
})
|
||||
|
||||
/* Gemeldet vom Eigentümer am 05.09.2026: Wartungsplan leer, Erstzulassung
|
||||
01.03.2025 - erwartet 01.03.2028. Angezeigt wurden stattdessen DREI
|
||||
verschiedene Antworten: Übersicht "01.08.2026", Mein Audi "August 2026",
|
||||
Service "kein Eintrag im Wartungsplan". Ursache war ein Profilfeld
|
||||
`hauptuntersuchung_faellig` ("08/2026"), das die Ableitung überstimmte,
|
||||
selbst aber in keiner Oberfläche ein Eingabefeld hatte. Vorgabe seither:
|
||||
"die eingabe reicht fürs erste mal in der Erstzulassung und danach über den
|
||||
Wartungsplan" - zwei Quellen, keine dritte. */
|
||||
describe("Hauptuntersuchung", () => {
|
||||
const buchMit = (datum: string) => [{ art: "Hauptuntersuchung", datum, km: 40000 }]
|
||||
|
||||
it("leitet die erste aus der Erstzulassung ab: plus 36 Monate", () => {
|
||||
expect(hauptuntersuchungFaellig([], "01.03.2025")).toBe("2028-03-01")
|
||||
})
|
||||
|
||||
it("liest die Erstzulassung in jeder Schreibweise gleich", () => {
|
||||
// Vor dem 05.09.2026 ergab dieselbe Erstzulassung je nach Schreibweise
|
||||
// 2028-02-28 oder 2028-03-01 - toISOString() rechnete nach UTC.
|
||||
for (const schreibweise of ["01.03.2025", "2025-03-01"]) {
|
||||
expect(hauptuntersuchungFaellig([], schreibweise)).toBe("2028-03-01")
|
||||
}
|
||||
})
|
||||
|
||||
it("rechnet ab dem Wartungsplan-Eintrag mit 24 Monaten statt 36", () => {
|
||||
expect(hauptuntersuchungFaellig(buchMit("2025-03-01"), "01.03.2025")).toBe("2027-03-01")
|
||||
})
|
||||
|
||||
it("lässt den Wartungsplan die Erstzulassung schlagen", () => {
|
||||
// Die Erstzulassung zählt nur bis zur ersten eingetragenen Untersuchung.
|
||||
const spaeter = hauptuntersuchungFaellig(buchMit("2026-06-15"), "01.03.2025")
|
||||
expect(spaeter).toBe("2028-06-15")
|
||||
})
|
||||
|
||||
it("verliert keinen Tag über die Zeitumstellung", () => {
|
||||
// monatePlus() rechnete früher über UTC und verlor dabei einen Tag,
|
||||
// sobald Start und Ziel in verschiedenen Zeitzonen-Halbjahren lagen.
|
||||
expect(hauptuntersuchungFaellig(buchMit("2025-01-15"), "")).toBe("2027-01-15")
|
||||
expect(hauptuntersuchungFaellig(buchMit("2025-11-20"), "")).toBe("2027-11-20")
|
||||
expect(hauptuntersuchungFaellig(buchMit("2025-02-10"), "")).toBe("2027-02-10")
|
||||
})
|
||||
|
||||
it("sagt nichts, wenn es weder Eintrag noch Erstzulassung gibt", () => {
|
||||
expect(hauptuntersuchungFaellig([], "")).toBeNull()
|
||||
expect(hauptuntersuchungFaellig([], null)).toBeNull()
|
||||
expect(hauptuntersuchungFaellig([], "keine Ahnung")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe("terminUeberschritten", () => {
|
||||
const tag = (versatz: number) => {
|
||||
const x = new Date()
|
||||
x.setDate(x.getDate() + versatz)
|
||||
return `${String(x.getDate()).padStart(2, "0")}.${String(x.getMonth() + 1).padStart(2, "0")}.${x.getFullYear()}`
|
||||
}
|
||||
|
||||
it("gestern ist überschritten", () => {
|
||||
expect(terminUeberschritten(tag(-1))).toBe(true)
|
||||
})
|
||||
|
||||
it("heute zählt mit - 'bereits fällig' ist gemeint", () => {
|
||||
expect(terminUeberschritten(tag(0))).toBe(true)
|
||||
})
|
||||
|
||||
it("morgen nicht", () => {
|
||||
expect(terminUeberschritten(tag(1))).toBe(false)
|
||||
})
|
||||
|
||||
it("erkennt den vollen Zeitstempel des Fahrzeugs", () => {
|
||||
// Genau die Form, die oelwechselFaelligTs/inspektionFaelligTs tragen. Im
|
||||
// Panel war das die Lücke: dessen Datumsleser ist verankert und nahm sie
|
||||
// nicht an, die Fahrzeugmeldung wäre dort still nie überschritten gewesen.
|
||||
expect(terminUeberschritten("2025-10-21T00:00:00+00:00")).toBe(true)
|
||||
expect(terminUeberschritten("2027-10-21T00:00:00+00:00")).toBe(false)
|
||||
})
|
||||
|
||||
it("versteht monatsgenaue Angaben (Hauptuntersuchung)", () => {
|
||||
expect(terminUeberschritten("03/2025")).toBe(true)
|
||||
expect(terminUeberschritten("03/2099")).toBe(false)
|
||||
})
|
||||
|
||||
it("ohne lesbares Datum wird nichts behauptet", () => {
|
||||
expect(terminUeberschritten(null)).toBe(false)
|
||||
expect(terminUeberschritten("")).toBe(false)
|
||||
expect(terminUeberschritten("irgendwas")).toBe(false)
|
||||
expect(terminUeberschritten(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it("nimmt das erste LESBARE Datum, nicht das erste beliebige", () => {
|
||||
// So kann die Markierung nicht von dem abweichen, was daneben steht.
|
||||
expect(terminUeberschritten(null, tag(-1))).toBe(true)
|
||||
expect(terminUeberschritten(tag(1), tag(-1))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
/**
|
||||
* Eigene Ölwechsel-Prognose. Portiert aus `oelwechselPrognose()` im alten
|
||||
* Panel.
|
||||
* Eigene Service-Prognosen (Ölwechsel, Inspektion). Portiert aus
|
||||
* `oelwechselPrognose()` im alten Panel; die Inspektion kam am 2026-08-17
|
||||
* hinzu (§ AGENTS.md, "Inspektion: voraussichtliches Datum ergänzen").
|
||||
*
|
||||
* 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.
|
||||
* Eintrag dieser Art — der allgemeine Langzeitdurchschnitt seit dem
|
||||
* allerersten Servicebucheintrag wäre dafür zu träge. Aus km und Tagen seit
|
||||
* dem letzten Eintrag ergibt sich die aktuelle Fahrleistung pro Tag;
|
||||
* hochgerechnet auf die verbleibenden km bis zum Intervall ergibt das ein
|
||||
* Datum, gedeckelt auf spätestens das Zeitlimit des Intervalls.
|
||||
*/
|
||||
|
||||
import { alsZeitpunkt, isoTag } from "../format"
|
||||
import type { Fahrzeug } from "./profilAdapter"
|
||||
|
||||
const TAG_MS = 86_400_000
|
||||
@@ -29,40 +31,60 @@ interface Buchhaltung {
|
||||
art?: string
|
||||
}
|
||||
|
||||
/* Durchgehend in ORTSZEIT gerechnet, ohne Umweg über UTC.
|
||||
|
||||
Vorher stand hier `new Date(isoDatum)` (das liest einen reinen Tag als
|
||||
UTC-Mitternacht) und am Ende `toISOString()` (das rechnet zurück). Fällt
|
||||
Start und Ziel in verschiedene Zeitumstellungen, verschiebt das um eine
|
||||
Stunde - und die reicht über Mitternacht. Am 05.09.2026 gemessen, drei
|
||||
von fünf Fällen daneben:
|
||||
|
||||
2025-01-15 + 6 Monate -> 2025-07-14 statt 2025-07-15
|
||||
2025-11-20 + 6 Monate -> 2026-05-19 statt 2026-05-20
|
||||
2025-02-10 + 4 Monate -> 2025-06-09 statt 2025-06-10
|
||||
|
||||
Betraf Ölwechsel- und Inspektionsprognose gleichermaßen. Das Panel
|
||||
rechnet in `monatePlus()` von jeher in Ortszeit und war damit richtig -
|
||||
eine stille Paritätsabweichung. */
|
||||
function monatePlus(isoDatum: string, monate: number): string {
|
||||
const d = new Date(isoDatum)
|
||||
const teile = /^(\d{4})-(\d{2})-(\d{2})/.exec(isoDatum)
|
||||
if (!teile) return isoDatum
|
||||
const d = new Date(Number(teile[1]), Number(teile[2]) - 1 + monate, Number(teile[3]))
|
||||
if (Number.isNaN(d.getTime())) return isoDatum
|
||||
d.setMonth(d.getMonth() + monate)
|
||||
return d.toISOString().slice(0, 10)
|
||||
const zwei = (n: number) => String(n).padStart(2, "0")
|
||||
return `${d.getFullYear()}-${zwei(d.getMonth() + 1)}-${zwei(d.getDate())}`
|
||||
}
|
||||
|
||||
/** Der jüngste Servicebucheintrag, dessen Art auf einen Ölwechsel hindeutet. */
|
||||
export function letzterOelwechsel(buch: readonly Buchhaltung[]): Buchhaltung | null {
|
||||
/** Der jüngste Servicebucheintrag, dessen Art auf das gesuchte Stichwort
|
||||
passt (z. B. "öl"/"oel" oder "inspektion"). */
|
||||
function letzterEintrag(buch: readonly Buchhaltung[], stichwort: RegExp): Buchhaltung | null {
|
||||
const treffer = buch
|
||||
.filter((e) => typeof e.art === "string" && /öl|oel/i.test(e.art) && e.datum && e.km != null)
|
||||
.filter((e) => typeof e.art === "string" && stichwort.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(
|
||||
/** Der jüngste Servicebucheintrag, dessen Art auf einen Ölwechsel hindeutet. */
|
||||
export function letzterOelwechsel(buch: readonly Buchhaltung[]): Buchhaltung | null {
|
||||
return letzterEintrag(buch, /öl|oel/i)
|
||||
}
|
||||
|
||||
/** Der jüngste Servicebucheintrag, dessen Art auf eine Inspektion hindeutet. */
|
||||
export function letzterInspektion(buch: readonly Buchhaltung[]): Buchhaltung | null {
|
||||
return letzterEintrag(buch, /inspektion/i)
|
||||
}
|
||||
|
||||
/** Gemeinsamer Kern für Ölwechsel- und Inspektionsprognose - beide rechnen
|
||||
identisch, nur mit anderem Intervall und anderer Suche im Servicebuch. */
|
||||
function servicePrognose(
|
||||
fahrzeug: Fahrzeug,
|
||||
buch: readonly Buchhaltung[],
|
||||
jetzt = new Date(),
|
||||
letzter: Buchhaltung | null,
|
||||
intervallKm: number,
|
||||
intervallMonate: number,
|
||||
jetzt: 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
|
||||
@@ -76,7 +98,7 @@ export function oelwechselPrognose(
|
||||
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
|
||||
// Ohne belastbare Fahrleistung (direkt nach dem Eintrag) gibt es keine
|
||||
// seriöse Kilometerprognose — dann gilt das Zeitlimit.
|
||||
if (tageSeit <= 0 || kmSeit <= 0) {
|
||||
return { datum: kappDatum, restKm, kmProTag: 0, durchZeitlimit: true }
|
||||
@@ -94,3 +116,324 @@ export function oelwechselPrognose(
|
||||
durchZeitlimit,
|
||||
}
|
||||
}
|
||||
|
||||
export function oelwechselPrognose(
|
||||
fahrzeug: Fahrzeug,
|
||||
buch: readonly Buchhaltung[],
|
||||
jetzt = new Date(),
|
||||
): Oelprognose | 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)
|
||||
return servicePrognose(fahrzeug, letzterOelwechsel(buch), intervallKm, intervallMonate, jetzt)
|
||||
}
|
||||
|
||||
/** Inspektion: feste 30.000 km / 24 Monate, wie auch das Fahrzeug selbst und
|
||||
das alte Panel rechnen - kein einstellbares Intervall wie beim Ölwechsel. */
|
||||
export function inspektionPrognose(
|
||||
fahrzeug: Fahrzeug,
|
||||
buch: readonly Buchhaltung[],
|
||||
jetzt = new Date(),
|
||||
): Oelprognose | null {
|
||||
return servicePrognose(fahrzeug, letzterEintrag(buch, /inspektion/i), 30000, 24, jetzt)
|
||||
}
|
||||
|
||||
interface Fahrtdaten {
|
||||
ts_start: string
|
||||
distance_km: number | null
|
||||
status?: string
|
||||
}
|
||||
|
||||
/** Fahrleistung pro Tag aus dem tatsächlichen Fahrtenlog, unabhängig vom
|
||||
Servicebuch - Grundlage für meldungsPrognose() unten, die auch ganz ohne
|
||||
Servicebucheintrag funktionieren soll. Nur abgeschlossene Fahrten mit
|
||||
bekannter Distanz zählen; die Rate ist der Durchschnitt seit der ältesten
|
||||
erfassten Fahrt, kein kurzes Zeitfenster - damit bleibt sie robust gegen
|
||||
einzelne fahrtenarme Tage. null ohne belastbare Datenlage. */
|
||||
export function kmProTagAusFahrten(fahrten: readonly Fahrtdaten[], jetzt = new Date()): number | null {
|
||||
const gueltig = fahrten.filter((f) => f.status !== "offen" && f.distance_km != null)
|
||||
if (!gueltig.length) return null
|
||||
const km = gueltig.reduce((s, f) => s + (f.distance_km ?? 0), 0)
|
||||
const aeltesteZeit = Math.min(...gueltig.map((f) => new Date(f.ts_start).getTime()))
|
||||
const tage = (jetzt.getTime() - aeltesteZeit) / TAG_MS
|
||||
return tage >= 1 ? km / tage : null
|
||||
}
|
||||
|
||||
export interface Meldungsprognose {
|
||||
/** ISO-Tagesdatum. */
|
||||
datum: string
|
||||
/** Die vom Fahrzeug gemeldete Zeitgrenze hat gegriffen, nicht die
|
||||
hochgerechnete Kilometergrenze - der Nutzer fährt zu wenig, um die
|
||||
km-Grenze rechtzeitig zu erreichen. */
|
||||
durchZeitlimit: boolean
|
||||
/** Restkilometer bis zur Fälligkeit, falls bekannt. */
|
||||
restKm: number | null
|
||||
}
|
||||
|
||||
/** Prognose rein aus der Fahrzeugmeldung, für den Fall ganz ohne
|
||||
Servicebucheintrag (siehe Aufrufer unten und in Service.tsx) - ein echter
|
||||
Servicebucheintrag bleibt vorrangig, näher am tatsächlichen letzten
|
||||
Service. Restkilometer aus der Meldung, hochgerechnet mit der echten
|
||||
Fahrleistung aus dem Fahrtenlog statt einer Servicebuch-Rate; gedeckelt
|
||||
auf das vom Fahrzeug selbst gemeldete Datum als Zeitgrenze. */
|
||||
export function meldungsPrognose(
|
||||
faelligTs: string | null,
|
||||
faelligKm: number | null,
|
||||
fahrten: readonly Fahrtdaten[],
|
||||
jetzt = new Date(),
|
||||
): Meldungsprognose | null {
|
||||
const zeitDatum = faelligTs ? faelligTs.slice(0, 10) : null
|
||||
if (faelligKm == null) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true, restKm: null } : null
|
||||
const restKm = Math.abs(faelligKm)
|
||||
if (restKm <= 0) return { datum: jetzt.toISOString().slice(0, 10), durchZeitlimit: false, restKm }
|
||||
const rate = kmProTagAusFahrten(fahrten, jetzt)
|
||||
if (!rate) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true, restKm } : null
|
||||
const kmDatum = new Date(jetzt.getTime() + (restKm / rate) * TAG_MS).toISOString().slice(0, 10)
|
||||
const durchZeitlimit = !!zeitDatum && zeitDatum < kmDatum
|
||||
return { datum: durchZeitlimit ? (zeitDatum as string) : kmDatum, durchZeitlimit, restKm }
|
||||
}
|
||||
|
||||
/** Eigenes (kürzeres) Intervall ganz ohne Servicebucheintrag: die
|
||||
Fahrzeugmeldung kennt nur das Herstellerintervall, nicht die in
|
||||
"Einrichten" gewählte kürzere Vorgabe. Ein angenommener letzter Service
|
||||
wird aus der gemeldeten Fälligkeit und der bekannten Herstellerintervall-
|
||||
Länge zurückgerechnet ("Fälligkeitspunkt minus Herstellerintervall"), und
|
||||
das eigene Intervall ab demselben angenommenen Punkt neu hochgerechnet -
|
||||
eine Näherung (der wahre letzte Service kann näher oder ferner liegen),
|
||||
aber ohne sie würde die eigene Intervallwahl bis zum ersten
|
||||
Servicebucheintrag komplett ignoriert (siehe oelMeldungsPrognose()). */
|
||||
export function meldungsPrognoseEigenesIntervall(
|
||||
faelligTs: string | null,
|
||||
faelligKm: number | null,
|
||||
herstellerKm: number | null,
|
||||
herstellerMonate: number | null,
|
||||
eigenKm: number | null,
|
||||
eigenMonate: number | null,
|
||||
fahrten: readonly Fahrtdaten[],
|
||||
jetzt = new Date(),
|
||||
): Meldungsprognose | null {
|
||||
if (faelligKm == null || !herstellerKm || eigenKm == null) return null
|
||||
const restKm = Math.max(0, Math.abs(faelligKm) - herstellerKm + eigenKm)
|
||||
const zeitDatum =
|
||||
faelligTs && herstellerMonate != null && eigenMonate != null
|
||||
? monatePlus(faelligTs, eigenMonate - herstellerMonate)
|
||||
: null
|
||||
if (restKm <= 0) return { datum: jetzt.toISOString().slice(0, 10), durchZeitlimit: false, restKm }
|
||||
const rate = kmProTagAusFahrten(fahrten, jetzt)
|
||||
if (!rate) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true, restKm } : null
|
||||
const kmDatum = new Date(jetzt.getTime() + (restKm / rate) * TAG_MS).toISOString().slice(0, 10)
|
||||
const durchZeitlimit = !!zeitDatum && zeitDatum < kmDatum
|
||||
return { datum: durchZeitlimit ? (zeitDatum as string) : kmDatum, durchZeitlimit, restKm }
|
||||
}
|
||||
|
||||
/** Fällt ganz ohne Servicebucheintrag auf die Fahrzeugmeldung zurück - bei
|
||||
Herstellervorgabe unverändert (die Meldung IST die Herstellervorgabe),
|
||||
bei eigenem (kürzerem) Intervall über meldungsPrognoseEigenesIntervall()
|
||||
hochgerechnet, damit die Intervallwahl in "Einrichten" auch ganz ohne
|
||||
Servicebucheintrag etwas bewirkt. Fehlen die Herstellerwerte selbst
|
||||
(kein Sensor dafür), fällt das sanft auf die reine Meldung zurück. */
|
||||
export function oelMeldungsPrognose(
|
||||
fahrzeug: Fahrzeug,
|
||||
fahrten: readonly Fahrtdaten[],
|
||||
jetzt = new Date(),
|
||||
): Meldungsprognose | null {
|
||||
const oel = fahrzeug.oel
|
||||
const roh = () => meldungsPrognose(fahrzeug.oelwechselFaelligTs, fahrzeug.oelwechselFaelligKm, fahrten, jetzt)
|
||||
if (oel.modus === "hersteller") return roh()
|
||||
const eigen = meldungsPrognoseEigenesIntervall(
|
||||
fahrzeug.oelwechselFaelligTs,
|
||||
fahrzeug.oelwechselFaelligKm,
|
||||
oel.herstellerKm,
|
||||
oel.herstellerMonate,
|
||||
oel.km,
|
||||
oel.monate,
|
||||
fahrten,
|
||||
jetzt,
|
||||
)
|
||||
return eigen ?? roh()
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ Nächster Service */
|
||||
|
||||
export type Serviceart = "Ölwechsel" | "Inspektion" | "Hauptuntersuchung"
|
||||
|
||||
export interface NaechsterService {
|
||||
art: Serviceart
|
||||
/** Restkilometer bis zur Fälligkeit. null bei der Hauptuntersuchung — die
|
||||
hat kein Kilometerziel, nur einen Termin. */
|
||||
restKm: number | null
|
||||
/** ISO-Tagesdatum der voraussichtlichen Fälligkeit. */
|
||||
datum: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Der zeitlich nächste anstehende Service über alle drei Arten hinweg —
|
||||
* Gegenstück zu `naechsterTermin()` im Panel.
|
||||
*
|
||||
* Ölwechsel und Inspektion kommen vorrangig aus der eigenen Prognose
|
||||
* (Servicebuch plus tatsächliche Fahrleistung); ohne Servicebucheintrag dazu
|
||||
* fällt jede der beiden einzeln auf meldungsPrognose() zurück - die vom
|
||||
* Fahrzeug selbst gemeldete Fälligkeit, hochgerechnet mit der Fahrleistung
|
||||
* aus dem Fahrtenlog. Ein frisch eingerichtetes Fahrzeug mit zugeordnetem
|
||||
* Sensor, aber noch ganz ohne Servicebuch, zeigte hier vorher "kein Eintrag
|
||||
* im Servicebuch", obwohl die Fälligkeit längst bekannt war. Die
|
||||
* Hauptuntersuchung kommt weiterhin nur aus dem im Profil gepflegten
|
||||
* Termin: sie hat kein Kilometerziel, das Fahrzeug meldet sie nicht, und
|
||||
* hochrechnen lässt sie sich auch nicht.
|
||||
*/
|
||||
export function naechsterService(
|
||||
fahrzeug: Fahrzeug,
|
||||
buch: readonly Buchhaltung[],
|
||||
fahrten: readonly Fahrtdaten[] = [],
|
||||
jetzt = new Date(),
|
||||
): NaechsterService | null {
|
||||
const kandidaten = serviceKandidaten(fahrzeug, buch, fahrten, jetzt)
|
||||
if (kandidaten.length === 0) return null
|
||||
// ISO-Tagesdaten, deshalb reicht der Zeichenkettenvergleich.
|
||||
return kandidaten.slice().sort((a, b) => a.datum.localeCompare(b.datum))[0] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle drei Arten mit ihrer voraussichtlichen Fälligkeit, ungeordnet.
|
||||
*
|
||||
* `naechsterService()` nimmt davon die früheste. Getrennt herausgezogen, weil
|
||||
* das Terminfeld auf der Serviceseite die Fälligkeit **je gewählter Art**
|
||||
* vorschlägt - so wie `termine()` im Panel, das dort dieselbe Liste liefert.
|
||||
* Eine Art ohne Grundlage (Hauptuntersuchung ohne gepflegtes HU-Datum) fehlt
|
||||
* in der Liste; der Aufrufer entscheidet, was er stattdessen anbietet.
|
||||
*/
|
||||
export function serviceKandidaten(
|
||||
fahrzeug: Fahrzeug,
|
||||
buch: readonly Buchhaltung[],
|
||||
fahrten: readonly Fahrtdaten[] = [],
|
||||
jetzt = new Date(),
|
||||
): NaechsterService[] {
|
||||
const kandidaten: NaechsterService[] = []
|
||||
|
||||
const oel = oelwechselPrognose(fahrzeug, buch, jetzt)
|
||||
if (oel) {
|
||||
kandidaten.push({ art: "Ölwechsel", restKm: oel.restKm, datum: oel.datum })
|
||||
} else {
|
||||
const m = oelMeldungsPrognose(fahrzeug, fahrten, jetzt)
|
||||
if (m) kandidaten.push({ art: "Ölwechsel", restKm: m.restKm, datum: m.datum })
|
||||
}
|
||||
|
||||
const insp = inspektionPrognose(fahrzeug, buch, jetzt)
|
||||
if (insp) {
|
||||
kandidaten.push({ art: "Inspektion", restKm: insp.restKm, datum: insp.datum })
|
||||
} else {
|
||||
const m = meldungsPrognose(fahrzeug.inspektionFaelligTs, fahrzeug.inspektionFaelligKm, fahrten, jetzt)
|
||||
if (m) kandidaten.push({ art: "Inspektion", restKm: m.restKm, datum: m.datum })
|
||||
}
|
||||
|
||||
/* Über dieselbe Ableitung wie Service und Mein Audi. Vorher stand hier
|
||||
`new Date(fahrzeug.hu)` auf einem Profilfeld, das die Ableitung gar nicht
|
||||
erst befragt hat - daher zeigte die Übersicht am 05.09.2026 den 01.08.2026,
|
||||
während Service "kein Eintrag im Wartungsplan" meldete. Drei Bildschirme,
|
||||
drei Antworten auf dieselbe Frage. */
|
||||
const hu = hauptuntersuchungFaellig(buch, fahrzeug.erstzulassung)
|
||||
if (hu) {
|
||||
kandidaten.push({ art: "Hauptuntersuchung", restKm: null, datum: hu })
|
||||
}
|
||||
|
||||
return kandidaten
|
||||
}
|
||||
|
||||
/* Die Hauptuntersuchung hat ZWEI Quellen — wortgleich mit
|
||||
hauptuntersuchungsTermin() im Panel:
|
||||
|
||||
1. der letzte Servicebuch-Eintrag plus **24** Monate.
|
||||
2. die Erstzulassung plus **36** Monate.
|
||||
|
||||
Die 36 gegen 24 sind kein Versehen: die erste Hauptuntersuchung eines
|
||||
Neuwagens ist nach drei Jahren fällig, jede weitere nach zwei (Vorgabe des
|
||||
Eigentümers, 05.09.2026).
|
||||
|
||||
Ein drittes, überstimmendes Profilfeld gibt es nicht mehr — Begründung im
|
||||
Panel bei hauptuntersuchungsTermin(). */
|
||||
export const HU_MONATE_FOLGE = 24
|
||||
export const HU_MONATE_ERSTE = 36
|
||||
|
||||
/** Der jüngste Servicebucheintrag, dessen Art auf eine Hauptuntersuchung
|
||||
hindeutet.
|
||||
|
||||
Verlangt bewusst KEINE Kilometerangabe, anders als letzterEintrag(): eine
|
||||
Hauptuntersuchung ist rein datumsbasiert, ihr Kilometerstand geht in keine
|
||||
Rechnung ein. Bis zum 05.09.2026 lief sie über letzterEintrag() und wurde
|
||||
damit stillschweigend übergangen, sobald der Eintrag kein km-Feld hatte -
|
||||
die App wäre dann auf die Erstzulassung zurückgefallen und hätte drei
|
||||
Jahre statt zwei gerechnet, während das Panel den Eintrag benutzte. */
|
||||
export function letzteHauptuntersuchung(buch: readonly Buchhaltung[]): Buchhaltung | null {
|
||||
const treffer = buch
|
||||
.filter((e) => typeof e.art === "string" && /hauptunter/i.test(e.art) && e.datum)
|
||||
.sort((a, b) => String(b.datum).localeCompare(String(a.datum)))
|
||||
return treffer[0] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Fälligkeit der Hauptuntersuchung, oder null wenn sich keine bestimmen
|
||||
* lässt.
|
||||
*
|
||||
* `null` heißt wirklich "unbekannt": es gibt weder einen Servicebuch-Eintrag
|
||||
* noch eine lesbare Erstzulassung. Dann steht in der Oberfläche ein Strich -
|
||||
* eine erfundene Fälligkeit wäre schlechter.
|
||||
*/
|
||||
export function hauptuntersuchungFaellig(
|
||||
buch: readonly Buchhaltung[],
|
||||
erstzulassung: string | null | undefined,
|
||||
): string | null {
|
||||
const eintrag = letzteHauptuntersuchung(buch)
|
||||
if (eintrag?.datum) return monatePlus(eintrag.datum, HU_MONATE_FOLGE)
|
||||
/* alsZeitpunkt() ist derselbe Parser, den auch die Anzeige benutzt, und
|
||||
liefert die Genauigkeit gleich mit: eine Erstzulassung "03/2025" nennt
|
||||
keinen Tag, also nennt der abgeleitete Termin auch keinen - "01.03.2028"
|
||||
wäre eine Erfindung. Das Ergebnis ist dann ein ISO-Monat ("2028-03"),
|
||||
den datum() als "März 2028" ausgibt. isoTag() rechnet den
|
||||
Zeitzonenversatz heraus; toISOString() allein verschöbe den Tag. */
|
||||
const z = alsZeitpunkt(erstzulassung)
|
||||
if (!z) return null
|
||||
const ziel = monatePlus(isoTag(z.zeit), HU_MONATE_ERSTE)
|
||||
return z.genau === "monat" ? ziel.slice(0, 7) : ziel
|
||||
}
|
||||
|
||||
/** Ist dieser Servicetermin bereits fällig oder überschritten?
|
||||
*
|
||||
* Der Fälligkeitstag selbst zählt mit („bereits fällig bzw. überschritten",
|
||||
* Ansage des Eigentümers 07.09.2026).
|
||||
*
|
||||
* Entschieden wird am DATUM, nicht an den Restkilometern - und das ist keine
|
||||
* Bequemlichkeit. Die Kilometerseite trägt je nach Quelle ein anderes
|
||||
* Vorzeichen: die Sensoren des Fahrzeugs melden negativ, solange noch Strecke
|
||||
* bleibt (am 07.09.2026 gemessen: oil_change_distance -28600 bei Fälligkeit
|
||||
* 2028), und servicePrognose() klemmt ihre Restkilometer ohnehin mit
|
||||
* Math.max(0, ...). Welches Vorzeichen ein überschrittener Fahrzeugwert trägt,
|
||||
* lässt sich an den vorliegenden Daten nicht beobachten - geraten wird es
|
||||
* nicht. Der km-Fall fällt ohnehin unter die Datumsregel: servicePrognose()
|
||||
* setzt datum auf heute, sobald restKm auf 0 läuft.
|
||||
*
|
||||
* Genommen wird das ERSTE lesbare Datum der übergebenen Kandidaten - also das,
|
||||
* das an dieser Stelle auch angezeigt wird. So kann die Markierung nicht von
|
||||
* dem abweichen, was daneben steht. Wortgleich mit terminUeberschritten() im
|
||||
* Panel. */
|
||||
export function terminUeberschritten(
|
||||
...kandidaten: (string | Date | null | undefined)[]
|
||||
): boolean {
|
||||
for (const kandidat of kandidaten) {
|
||||
if (kandidat === null || kandidat === undefined || kandidat === "") continue
|
||||
const z = alsZeitpunkt(kandidat)
|
||||
if (!z) continue
|
||||
const d = z.zeit
|
||||
const h = new Date()
|
||||
return (
|
||||
new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime() <=
|
||||
new Date(h.getFullYear(), h.getMonth(), h.getDate()).getTime()
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -46,25 +46,46 @@ function alsTag(d: Date): string {
|
||||
}
|
||||
|
||||
describe("zeitraumStart", () => {
|
||||
it("beginnt das Jahr am 1. Januar", () => {
|
||||
expect(alsTag(zeitraumStart("jahr", JETZT))).toBe("2026-01-01")
|
||||
it("rechnet das Jahr 365 Tage zurück", () => {
|
||||
expect(alsTag(zeitraumStart("jahr", JETZT))).toBe("2025-08-11")
|
||||
})
|
||||
|
||||
it("beginnt den Monat am Ersten", () => {
|
||||
expect(alsTag(zeitraumStart("monat", JETZT))).toBe("2026-08-01")
|
||||
it("rechnet den Monat 30 Tage zurück", () => {
|
||||
expect(alsTag(zeitraumStart("monat", JETZT))).toBe("2026-07-12")
|
||||
})
|
||||
|
||||
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("rechnet die Woche 7 Tage zurück", () => {
|
||||
expect(alsTag(zeitraumStart("woche", JETZT))).toBe("2026-08-04")
|
||||
})
|
||||
|
||||
it("beginnt den Tag um Mitternacht", () => {
|
||||
it("rechnet den Tag 24 Stunden zurück", () => {
|
||||
const start = zeitraumStart("tag", JETZT)
|
||||
expect(start.getHours()).toBe(0)
|
||||
expect(start.getDate()).toBe(11)
|
||||
expect(alsTag(start)).toBe("2026-08-10")
|
||||
expect(start.getHours()).toBe(12)
|
||||
})
|
||||
|
||||
/* Der Grund für die Umstellung, als Test: am 02.09.2026 begann die
|
||||
Kalenderwoche am Montag, dem 31.08., der Kalendermonat aber erst am
|
||||
01.09. Eine Fahrt vom 31.08. zählte damit zur Woche und nicht zum
|
||||
Monat - "Monat kleiner als Woche", vom Eigentümer an der realen Instanz
|
||||
gemeldet. Rollende Fenster sind ineinander enthalten, immer. */
|
||||
it("hält Tag, Woche, Monat und Jahr ineinander verschachtelt", () => {
|
||||
for (const tag of ["2026-09-02T09:00:00", "2026-01-01T09:00:00", "2026-03-01T00:30:00"]) {
|
||||
const jetzt = new Date(tag)
|
||||
const [j, m, w, t] = (["jahr", "monat", "woche", "tag"] as const).map((art) =>
|
||||
zeitraumStart(art, jetzt).getTime(),
|
||||
)
|
||||
expect(j!).toBeLessThanOrEqual(m!)
|
||||
expect(m!).toBeLessThanOrEqual(w!)
|
||||
expect(w!).toBeLessThanOrEqual(t!)
|
||||
}
|
||||
})
|
||||
|
||||
it("zählt eine Fahrt vom Monatsletzten in Woche UND Monat", () => {
|
||||
const jetzt = new Date("2026-09-02T09:00:00")
|
||||
const fahrten = [fahrt({ trip_id: "a", ts_start: "2026-08-31T08:00:00" })]
|
||||
expect(fahrtenSeit(fahrten, "woche", jetzt)).toHaveLength(1)
|
||||
expect(fahrtenSeit(fahrten, "monat", jetzt)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -91,7 +112,9 @@ describe("fahrtenSeit und tankSeit", () => {
|
||||
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" }),
|
||||
// Im Kalenderjahr 2026 nicht enthalten, in den letzten 365 Tagen schon -
|
||||
// seit der Umstellung auf rollende Fenster zählt sie deshalb mit.
|
||||
fahrt({ trip_id: "dezember", ts_start: "2025-12-30T09:00:00" }),
|
||||
fahrt({ trip_id: "ohnestrecke", ts_start: "2026-08-11T10:00:00", distance_km: null }),
|
||||
]
|
||||
|
||||
@@ -101,7 +124,7 @@ describe("fahrtenSeit und tankSeit", () => {
|
||||
"heute",
|
||||
"diesewoche",
|
||||
])
|
||||
expect(fahrtenSeit(fahrten, "jahr", JETZT)).toHaveLength(3)
|
||||
expect(fahrtenSeit(fahrten, "jahr", JETZT)).toHaveLength(4)
|
||||
})
|
||||
|
||||
it("lässt Fahrten ohne Strecke außen vor — sie würden jede Summe verfälschen", () => {
|
||||
@@ -118,19 +141,48 @@ describe("fahrtenSeit und tankSeit", () => {
|
||||
})
|
||||
|
||||
describe("langzeitverbrauch", () => {
|
||||
it("rechnet bevorzugt mit dem Kilometerstand an den Tankvorgängen", () => {
|
||||
it("rechnet von Tank zu Tank, ohne den ersten Tankvorgang", () => {
|
||||
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)
|
||||
// Nur die 50 l aus Tankvorgang b: gemessen wird die Strecke ZWISCHEN a
|
||||
// und b, und was dort verbraucht wurde, ist genau das, was bei b wieder
|
||||
// hineingefüllt wurde. Die 50 l aus a füllten die Strecke davor - sie
|
||||
// mitzuzählen war bis zum 2026-08-31 der Fehler und wies den Verbrauch
|
||||
// um einen ganzen Tankinhalt zu hoch aus (hier: 16,7 statt 8,3).
|
||||
expect(ergebnis.liter).toBe(50)
|
||||
expect(ergebnis.wert).toBeCloseTo(8.33, 1)
|
||||
expect(ergebnis.basis).toBe("Kilometerstand an den Tankvorgängen")
|
||||
})
|
||||
|
||||
it("zählt bei drei Tankvorgängen die letzten beiden", () => {
|
||||
const tanks = [
|
||||
tankung({ tank_id: "a", ts: "2026-05-01T10:00:00", liters: 40, odometer_km: 10000 }),
|
||||
tankung({ tank_id: "b", ts: "2026-06-01T10:00:00", liters: 45, odometer_km: 10500 }),
|
||||
tankung({ tank_id: "c", ts: "2026-07-01T10:00:00", liters: 55, odometer_km: 11000 }),
|
||||
]
|
||||
const ergebnis = langzeitverbrauch([], tanks)
|
||||
expect(ergebnis.km).toBe(1000)
|
||||
expect(ergebnis.liter).toBe(100)
|
||||
expect(ergebnis.wert).toBeCloseTo(10, 1)
|
||||
})
|
||||
|
||||
it("lässt eine rückwärts laufende Kilometerreihe nicht als Wert durchgehen", () => {
|
||||
// Ein falscher Kilometerstand an einem Tankvorgang ist in diesem Projekt
|
||||
// schon vorgekommen (AGENTS.md, Abschnitt AF). Eine negative Strecke ist
|
||||
// kein Messwert.
|
||||
const tanks = [
|
||||
tankung({ tank_id: "a", ts: "2026-06-01T10:00:00", liters: 50, odometer_km: 40600 }),
|
||||
tankung({ tank_id: "b", ts: "2026-07-01T10:00:00", liters: 50, odometer_km: 40000 }),
|
||||
]
|
||||
const ergebnis = langzeitverbrauch([], tanks)
|
||||
expect(ergebnis.km).toBe(0)
|
||||
expect(ergebnis.wert).toBeNull()
|
||||
})
|
||||
|
||||
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 })]
|
||||
@@ -176,7 +228,8 @@ describe("jahresauswertung", () => {
|
||||
})
|
||||
|
||||
describe("nachJahrUndMonat", () => {
|
||||
it("gruppiert absteigend, Neuestes zuerst", () => {
|
||||
// Durchgehend absteigend, in beiden Anwendungen (Designprüfung 2026-08-30).
|
||||
it("gruppiert durchgehend absteigend, Neuestes zuerst", () => {
|
||||
const eintraege = [
|
||||
{ id: "alt", ts: "2025-03-04T10:00:00" },
|
||||
{ id: "neu", ts: "2026-08-01T10:00:00" },
|
||||
|
||||
@@ -9,25 +9,54 @@
|
||||
import type { Fahrt, Tankvorgang } from "../api"
|
||||
import { summe } from "../format"
|
||||
|
||||
export type Zeitraum = "jahr" | "monat" | "woche" | "tag"
|
||||
/** "absolut" ist kein Zeitraum, sondern alles je Erfasste - die Summe. */
|
||||
export type Zeitraum = "jahr" | "monat" | "woche" | "tag" | "absolut"
|
||||
|
||||
export const ZEITRAEUME: Zeitraum[] = ["jahr", "monat", "woche", "tag"]
|
||||
/** Alle Zeiträume, die berechnet werden - einschließlich "absolut". */
|
||||
export const ZEITRAEUME: Zeitraum[] = ["jahr", "monat", "woche", "tag", "absolut"]
|
||||
|
||||
/** Die Spalten, die eine Kachel zeigt.
|
||||
|
||||
"absolut" fehlt hier bewusst: der Gesamtwert steht seit dem 2026-08-31
|
||||
als große Zahl in der Kopfzeile der Kachel (Ansage des Eigentümers). Eine
|
||||
fünfte Spalte passte auf dem Telefon ohnehin nicht in eine Zeile - die
|
||||
Kachel ist dort innen 307px breit, ein sechsstelliger Wert 67px. */
|
||||
export const ZEITRAUM_SPALTEN: Zeitraum[] = ["jahr", "monat", "woche", "tag"]
|
||||
|
||||
export const ZEITRAUM_NAME: Record<Zeitraum, string> = {
|
||||
jahr: "Jahr",
|
||||
monat: "Monat",
|
||||
woche: "Woche",
|
||||
tag: "Tag",
|
||||
absolut: "Absolut",
|
||||
}
|
||||
|
||||
/** Beginn des Zeitraums. Die Woche startet montags, nicht sonntags. */
|
||||
/**
|
||||
* Länge der rollenden Zeiträume in Tagen.
|
||||
*
|
||||
* Bis zum 02.09.2026 waren die Spannen kalendarisch: Jahr ab 1. Januar, Monat
|
||||
* ab dem Ersten, Woche ab Montag, Tag ab Mitternacht. Das ergab regelmäßig
|
||||
* einen unmöglichen Vergleich — am 02.09.2026 stand in der realen Instanz
|
||||
* "Monat" UNTER "Woche", weil die Woche am Montag, dem 31.08., begann und
|
||||
* damit zwei Tage mitzählte, die der Monat nicht kennt. Zwischen Monat und
|
||||
* Jahr passiert dasselbe am Jahreswechsel.
|
||||
*
|
||||
* Rollende Fenster können das nicht: 1 ⊂ 7 ⊂ 30 ⊂ 365 gilt an jedem Tag des
|
||||
* Jahres. Der Preis ist, dass "Jahr" nicht mehr das Kalenderjahr meint,
|
||||
* sondern die letzten 365 Tage — so entschieden vom Eigentümer am 02.09.2026.
|
||||
*/
|
||||
export const ZEITRAUM_TAGE: Record<Exclude<Zeitraum, "absolut">, number> = {
|
||||
jahr: 365,
|
||||
monat: 30,
|
||||
woche: 7,
|
||||
tag: 1,
|
||||
}
|
||||
|
||||
/** Beginn des Zeitraums: so viele Tage zurück, wie ZEITRAUM_TAGE sagt. */
|
||||
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
|
||||
// Vor jeder denkbaren Aufzeichnung - damit fällt nichts heraus.
|
||||
if (art === "absolut") return new Date(0)
|
||||
return new Date(jetzt.getTime() - ZEITRAUM_TAGE[art] * 24 * 60 * 60 * 1000)
|
||||
}
|
||||
|
||||
export function fahrtenSeit(fahrten: readonly Fahrt[], art: Zeitraum, jetzt = new Date()): Fahrt[] {
|
||||
@@ -88,33 +117,51 @@ export function langzeitverbrauch(
|
||||
|
||||
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 liter: 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"
|
||||
// Von-Tank-zu-Tank: gemessen wird die Strecke ZWISCHEN erstem und
|
||||
// letztem Tankstop, verbraucht wurde auf ihr alles, was ab dem zweiten
|
||||
// Tankstop hineingefüllt wurde. Der erste Tankvorgang füllte die Strecke
|
||||
// DAVOR — ihn mitzuzählen hat den Verbrauch um einen ganzen Tankinhalt
|
||||
// zu hoch ausgewiesen (bei 13 Tankvorgängen rund 8 %). Korrigiert am
|
||||
// 2026-08-31.
|
||||
liter = summe(mitOdo.slice(1), (t) => t.liters ?? 0)
|
||||
} else {
|
||||
km = summe(
|
||||
fahrten.filter((f) => f.distance_km != null && new Date(f.ts_start) <= bis),
|
||||
(f) => f.distance_km ?? 0,
|
||||
)
|
||||
basis = "erfasste Fahrten"
|
||||
// Ohne Kilometerstände ist die Bezugsstrecke alles Erfasste bis zum
|
||||
// letzten Tankstop — dann zählt auch der erste Tankvorgang dazu.
|
||||
liter = summe(tanks, (t) => t.liters ?? 0)
|
||||
}
|
||||
// Eine negative Strecke ist kein Messwert, sondern ein falscher
|
||||
// Kilometerstand an einem Tankvorgang. Sie darf nicht als Zahl durchgehen.
|
||||
if (km < 0) km = 0
|
||||
|
||||
const roh = km > 0 ? (liter / km) * 100 : null
|
||||
const gueltig = roh !== null && roh >= 3 && roh <= 30
|
||||
// Auch eine Strecke von null zählt als unplausibel, wenn sie aus
|
||||
// Kilometerständen kommt: dann laufen die Werte an den Tankvorgängen
|
||||
// rückwärts. Ohne diesen Zweig stünde dort nur „0 km" und „–", ohne jeden
|
||||
// Hinweis, woran es liegt.
|
||||
const streckeFehlt = basis === "Kilometerstand an den Tankvorgängen" && km <= 0
|
||||
return {
|
||||
wert: gueltig ? roh : null,
|
||||
km,
|
||||
liter,
|
||||
bis,
|
||||
basis,
|
||||
unplausibel: roh !== null && !gueltig,
|
||||
unplausibel: (roh !== null && !gueltig) || streckeFehlt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,16 +171,36 @@ export interface Jahresauswertung {
|
||||
stundenJahr: number
|
||||
literJahr: number
|
||||
kostenJahr: number
|
||||
/** Über den gesamten erfassten Zeitraum - die Zahl in der Kopfzeile. */
|
||||
kmGesamt: number
|
||||
stundenGesamt: number
|
||||
literGesamt: number
|
||||
kostenGesamt: number
|
||||
tagKm: number
|
||||
nachtKm: number
|
||||
arbeitKm: number
|
||||
privatKm: number
|
||||
arbeitFahrten: number
|
||||
privatFahrten: number
|
||||
jeZeitraum: Record<Zeitraum, { km: number; stunden: number; liter: number; kosten: number }>
|
||||
jeZeitraum: Record<
|
||||
Zeitraum,
|
||||
{ km: number; stunden: number; liter: number; kosten: number; verbrauch: number | null }
|
||||
>
|
||||
langzeit: Langzeitverbrauch
|
||||
}
|
||||
|
||||
/**
|
||||
* Nach Distanz gewichteter Mittelwert der genäherten Fahrtverbräuche —
|
||||
* `schnittFahrtVerbrauch()` im Panel. Die Kachel „Langzeitverbrauch" zeigt
|
||||
* damit einen Näherungswert je Zeitraum; hier fehlte er bisher.
|
||||
*/
|
||||
export function schnittFahrtVerbrauch(fahrten: readonly Fahrt[]): number | null {
|
||||
const mit = fahrten.filter((f) => f.verbrauch_l_100km != null && (f.distance_km ?? 0) > 0)
|
||||
const km = summe(mit, (f) => f.distance_km ?? 0)
|
||||
if (km <= 0) return null
|
||||
return summe(mit, (f) => (f.verbrauch_l_100km ?? 0) * (f.distance_km ?? 0)) / km
|
||||
}
|
||||
|
||||
export function jahresauswertung(
|
||||
fahrten: readonly Fahrt[],
|
||||
tankvorgaenge: readonly Tankvorgang[],
|
||||
@@ -150,6 +217,7 @@ export function jahresauswertung(
|
||||
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),
|
||||
verbrauch: schnittFahrtVerbrauch(f),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +236,10 @@ export function jahresauswertung(
|
||||
stundenJahr: jeZeitraum.jahr.stunden,
|
||||
literJahr: jeZeitraum.jahr.liter,
|
||||
kostenJahr: jeZeitraum.jahr.kosten,
|
||||
kmGesamt: jeZeitraum.absolut.km,
|
||||
stundenGesamt: jeZeitraum.absolut.stunden,
|
||||
literGesamt: jeZeitraum.absolut.liter,
|
||||
kostenGesamt: jeZeitraum.absolut.kosten,
|
||||
tagKm,
|
||||
nachtKm: kmJahr - tagKm,
|
||||
arbeitKm,
|
||||
@@ -205,6 +277,11 @@ export function nachJahrUndMonat<T>(
|
||||
): JahresGruppe<T>[] {
|
||||
const jahre = new Map<number, Map<string, MonatsGruppe<T>>>()
|
||||
|
||||
/* Durchgehend absteigend: Jahr, Monat und Zeile. Bis zur Designprüfung
|
||||
2026-08-30 liefen Monate und Zeilen aufsteigend (der Panel-Stand von
|
||||
damals) — in einer Liste, deren Jahre absteigen, sind das zwei
|
||||
Richtungen gleichzeitig, und die neueste Fahrt landete unten außerhalb
|
||||
des sichtbaren Bereichs. Das Panel ist mitgezogen (neuesteZuerst()). */
|
||||
const sortiert = eintraege
|
||||
.slice()
|
||||
.sort((a, b) => new Date(zeitpunktVon(b)).getTime() - new Date(zeitpunktVon(a)).getTime())
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Tests zur Tankstellen-Anzeige und zum Kartendienst.
|
||||
*
|
||||
* Die Anschriften stammen aus echten Belegen der Testinstanz (04.09.2026) -
|
||||
* genau an ihnen ist aufgefallen, dass die Wertespalte zu schmal war und die
|
||||
* App die Tankstelle ohne Anschrift gar nicht erst nachschlug.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import {
|
||||
kartendienstUrl,
|
||||
markeErkennen,
|
||||
ohneMarkeVorn,
|
||||
tankstelleKandidaten,
|
||||
tankstelleSuchtext,
|
||||
tankstelleKurz,
|
||||
tankstelleTeile,
|
||||
tankstelleZeilen,
|
||||
tankstelleZiel,
|
||||
} from "./tankstelle"
|
||||
|
||||
function alsGeraet(kennung: string) {
|
||||
vi.stubGlobal("navigator", { userAgent: kennung, platform: "" })
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe("markeErkennen", () => {
|
||||
it("findet die Kopfmarke im gespeicherten Namen", () => {
|
||||
expect(markeErkennen("Shell, SÖLDEN")).toBe("Shell")
|
||||
expect(markeErkennen("ARAL Station")).toBe("Aral")
|
||||
})
|
||||
|
||||
it("haelt die Firmierung des Betreibers NICHT fuer eine Marke", () => {
|
||||
// Die vier Belege der Testinstanz mit Anschrift tragen genau das.
|
||||
expect(markeErkennen("A. Zrenner GmbH")).toBeNull()
|
||||
expect(markeErkennen("Hermann Mogler Mineralölg. GmbH")).toBeNull()
|
||||
expect(markeErkennen("TC Sengül GmbH")).toBeNull()
|
||||
})
|
||||
|
||||
it("achtet auf Wortgrenzen", () => {
|
||||
// "Star" ist eine Marke, "Starnberg" ist keine.
|
||||
expect(markeErkennen("Tankstelle Starnberg")).toBeNull()
|
||||
expect(markeErkennen("STAR Tankstelle")).toBe("Star")
|
||||
})
|
||||
|
||||
it("nimmt TotalEnergies vor Total", () => {
|
||||
expect(markeErkennen("TotalEnergies Station")).toBe("TotalEnergies")
|
||||
})
|
||||
})
|
||||
|
||||
describe("tankstelleZeilen", () => {
|
||||
it("setzt Kopfmarke und Strasse in die erste Zeile, PLZ und Ort in die zweite", () => {
|
||||
expect(tankstelleZeilen("Shell, SÖLDEN", "Pascalstr. 8, 85057 Ingolstadt")).toEqual({
|
||||
oben: "Shell, Pascalstr. 8",
|
||||
unten: "85057 Ingolstadt",
|
||||
})
|
||||
})
|
||||
|
||||
it("laesst den Betreibernamen weg - nach aussen sichtbar ist die Kopfmarke", () => {
|
||||
expect(tankstelleZeilen("A. Zrenner GmbH", "Pascalstr. 8, 85057 Ingolstadt")).toEqual({
|
||||
oben: "Pascalstr. 8",
|
||||
unten: "85057 Ingolstadt",
|
||||
})
|
||||
})
|
||||
|
||||
it("zeigt ohne Anschrift den gespeicherten Namen unveraendert", () => {
|
||||
// Sonst hiesse eine bekannte Tankstelle plötzlich "unbekannt".
|
||||
// Geaendert am 04.09.2026: die Marke gehoert nach oben, der Ort nach unten.
|
||||
expect(tankstelleZeilen("Shell, SÖLDEN", null)).toEqual({ oben: "Shell", unten: "SÖLDEN" })
|
||||
expect(tankstelleZeilen("Testtankstelle", null)).toEqual({ oben: "Testtankstelle", unten: "" })
|
||||
})
|
||||
|
||||
it("kommt ohne Namen aus", () => {
|
||||
expect(tankstelleZeilen("", "Pascalstr. 8, 85057 Ingolstadt")).toEqual({
|
||||
oben: "Pascalstr. 8",
|
||||
unten: "85057 Ingolstadt",
|
||||
})
|
||||
})
|
||||
|
||||
it("sagt „unbekannt“, wenn der Beleg nichts hergibt", () => {
|
||||
expect(tankstelleZeilen(null, null)).toEqual({ oben: "unbekannt", unten: "" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("tankstelleKandidaten", () => {
|
||||
it("stellt die Anschrift voran und nimmt den Namen NICHT dazu", () => {
|
||||
// Gemessen am 04.09.2026: Nominatim findet die Anschrift, mit dem
|
||||
// Betreibernamen davor findet es nichts.
|
||||
expect(tankstelleKandidaten("A. Zrenner GmbH", "Pascalstr. 8, 85057 Ingolstadt")).toEqual([
|
||||
"Pascalstr. 8, 85057 Ingolstadt",
|
||||
"A. Zrenner GmbH",
|
||||
])
|
||||
})
|
||||
|
||||
it("laesst leere Teile weg", () => {
|
||||
// Der Ort allein ist der letzte Versuch, er laeuft nur, wenn die beiden
|
||||
// davor nichts gefunden haben - schaden kann er dann nicht.
|
||||
expect(tankstelleKandidaten("Shell, SÖLDEN", null)).toEqual(["Shell, SÖLDEN", "SÖLDEN"])
|
||||
expect(tankstelleKandidaten(null, null)).toEqual([])
|
||||
})
|
||||
|
||||
it("versucht zuletzt den Namen ohne die fuehrende Marke", () => {
|
||||
// Der Fall der realen Instanz: der Parser hat "Marke, Strasse, Ort" in den
|
||||
// Namen geschrieben, eine Postleitzahl fehlt. Gemessen am 04.09.2026:
|
||||
// mit "Shell," davor findet Nominatim nichts, ohne findet es die Strasse.
|
||||
expect(tankstelleKandidaten("Shell, Pascalstr. 8, Ingolstadt", null)).toEqual([
|
||||
"Shell, Pascalstr. 8, Ingolstadt",
|
||||
"Pascalstr. 8, Ingolstadt",
|
||||
])
|
||||
})
|
||||
|
||||
it("schneidet nichts ab, wenn die Marke nicht vorn steht", () => {
|
||||
expect(tankstelleKandidaten("Tankstelle Shell", null)).toEqual(["Tankstelle Shell"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("tankstelleZiel", () => {
|
||||
it("nimmt fuer den Kartendienst Kopfmarke und Anschrift zusammen", () => {
|
||||
expect(tankstelleZiel("Shell, SÖLDEN", "Pascalstr. 8, 85057 Ingolstadt")).toBe(
|
||||
"Shell, Pascalstr. 8, 85057 Ingolstadt",
|
||||
)
|
||||
})
|
||||
|
||||
it("schickt ohne erkennbare Marke die Anschrift allein hin", () => {
|
||||
expect(tankstelleZiel("A. Zrenner GmbH", "Pascalstr. 8, 85057 Ingolstadt")).toBe(
|
||||
"Pascalstr. 8, 85057 Ingolstadt",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("tankstelleSuchtext", () => {
|
||||
it("ist der erste Kandidat, also die Anschrift", () => {
|
||||
expect(tankstelleSuchtext("A. Zrenner GmbH", "Pascalstr. 8, 85057 Ingolstadt")).toBe(
|
||||
"Pascalstr. 8, 85057 Ingolstadt",
|
||||
)
|
||||
})
|
||||
|
||||
it("faellt auf den Namen zurueck, wenn keine Anschrift im Beleg steht", () => {
|
||||
// Genau der Fall, in dem die App bis zum 04.09.2026 gar nichts nachschlug,
|
||||
// das Panel dagegen schon (f.station_address || f.station_name).
|
||||
expect(tankstelleSuchtext("Shell, SÖLDEN", null)).toBe("Shell, SÖLDEN")
|
||||
})
|
||||
|
||||
it("ist leer, wenn beides fehlt", () => {
|
||||
expect(tankstelleSuchtext(null, "")).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("kartendienstUrl", () => {
|
||||
it("nimmt auf dem iPhone Apple Karten", () => {
|
||||
alsGeraet("Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)")
|
||||
expect(kartendienstUrl("Shell, SÖLDEN", "Pascalstr. 8, 85057 Ingolstadt")).toBe(
|
||||
"https://maps.apple.com/?daddr=Shell%2C%20Pascalstr.%208%2C%2085057%20Ingolstadt",
|
||||
)
|
||||
})
|
||||
|
||||
it("zielt mit Koordinaten auf den Punkt und beschriftet ihn", () => {
|
||||
alsGeraet("Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)")
|
||||
expect(
|
||||
kartendienstUrl("A. Zrenner GmbH", "Pascalstr. 8, 85057 Ingolstadt", {
|
||||
lat: 48.7869101,
|
||||
lon: 11.39767,
|
||||
}),
|
||||
).toBe("https://maps.apple.com/?daddr=48.7869101%2C11.39767&q=A.%20Zrenner%20GmbH")
|
||||
})
|
||||
|
||||
it("nimmt sonst Google Maps", () => {
|
||||
alsGeraet("Mozilla/5.0 (X11; Linux x86_64)")
|
||||
expect(kartendienstUrl(null, "Pascalstr. 8, 85057 Ingolstadt")).toBe(
|
||||
"https://www.google.com/maps/dir/?api=1&destination=Pascalstr.%208%2C%2085057%20Ingolstadt",
|
||||
)
|
||||
})
|
||||
|
||||
it("bleibt null, wenn nichts bekannt ist", () => {
|
||||
alsGeraet("Mozilla/5.0 (iPhone)")
|
||||
expect(kartendienstUrl(null, null)).toBeNull()
|
||||
})
|
||||
|
||||
it("zeigt im Modus \"zeigen\" nur die Tankstelle, ohne Route", () => {
|
||||
alsGeraet("Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)")
|
||||
const url = kartendienstUrl("Shell, SÖLDEN", "Pascalstr. 8, 85057 Ingolstadt", null, "zeigen")
|
||||
expect(url).toContain("maps.apple.com/?q=")
|
||||
expect(url).not.toContain("daddr")
|
||||
})
|
||||
|
||||
it("nimmt bei Google die Suche statt der Routenplanung", () => {
|
||||
alsGeraet("Mozilla/5.0 (X11; Linux x86_64)")
|
||||
expect(kartendienstUrl(null, "Pascalstr. 8, 85057 Ingolstadt", null, "zeigen")).toBe(
|
||||
"https://www.google.com/maps/search/?api=1&query=Pascalstr.%208%2C%2085057%20Ingolstadt",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
/* Die Vorgabe des Eigentuemers vom 04.09.2026, an den ECHTEN Datensaetzen der
|
||||
Instanz geprueft - beide Bauformen des Bestandes:
|
||||
|
||||
bis 2025: name = Firmierung des Betreibers, Anschrift daneben
|
||||
ab 08.2026: name = "Marke, Strasse, Ort", keine Anschrift
|
||||
|
||||
Uebersicht: Marke, Strasse ohne Hausnummer, Ort ohne Postleitzahl
|
||||
Einzelbeleg: oben Marke + Strasse mit Hausnummer, unten Postleitzahl + Ort */
|
||||
describe("tankstelleKurz und tankstelleZeilen an echten Belegen", () => {
|
||||
it("holt Strasse und Ort aus der Anschrift, wenn im Namen nur die Firmierung steht", () => {
|
||||
// Genau der gemeldete Beleg vom 10.11.2025.
|
||||
const n = "TC Sengül GmbH"
|
||||
const a = "Nürnberger Str.74, 91522 Ansbach"
|
||||
expect(tankstelleKurz(n, a)).toBe("Nürnberger Str., Ansbach")
|
||||
expect(tankstelleZeilen(n, a)).toEqual({ oben: "Nürnberger Str. 74", unten: "91522 Ansbach" })
|
||||
})
|
||||
|
||||
it("zerlegt auch den zusammengesetzten Namen der neueren Belege", () => {
|
||||
const n = "Shell, Pascalstr. 8, Ingolstadt"
|
||||
expect(tankstelleKurz(n, null)).toBe("Shell, Pascalstr., Ingolstadt")
|
||||
expect(tankstelleZeilen(n, null)).toEqual({ oben: "Shell, Pascalstr. 8", unten: "Ingolstadt" })
|
||||
})
|
||||
|
||||
it("kuerzt die ausgeschriebene Strasse", () => {
|
||||
const n = "Aral, Nürnberger Straße 74, 91522 Ansbach"
|
||||
expect(tankstelleKurz(n, null)).toBe("Aral, Nürnberger Str., Ansbach")
|
||||
})
|
||||
|
||||
it("laesst die Firmierung ueberall weg", () => {
|
||||
expect(tankstelleKurz("Hermann Mogler Mineralölg. GmbH", "Heidenheimerstr.6, 89551 Königsbronn")).toBe(
|
||||
"Heidenheimerstr., Königsbronn",
|
||||
)
|
||||
})
|
||||
|
||||
it("behaelt den rohen Namen, wenn sich sonst nichts gewinnen laesst", () => {
|
||||
// Sonst hiesse eine bekannte Tankstelle ploetzlich "unbekannt".
|
||||
expect(tankstelleKurz("Testtankstelle", null)).toBe("Testtankstelle")
|
||||
expect(tankstelleKurz("A. Zrenner GmbH", null)).toBe("A. Zrenner GmbH")
|
||||
expect(tankstelleKurz(null, null)).toBe("unbekannt")
|
||||
})
|
||||
|
||||
it("trennt Hausnummer auch ohne Leerzeichen davor", () => {
|
||||
// So drucken die Kassen es: "Str.74", nicht "Str. 74".
|
||||
expect(tankstelleTeile(null, "Heidenheimerstr.6, 89551 Königsbronn")).toMatchObject({
|
||||
strasse: "Heidenheimerstr.",
|
||||
hausnummer: "6",
|
||||
plz: "89551",
|
||||
ort: "Königsbronn",
|
||||
})
|
||||
expect(tankstelleTeile(null, "Am Anger 7a, 85111 Adelschlag")).toMatchObject({
|
||||
strasse: "Am Anger",
|
||||
hausnummer: "7a",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("gespeicherte Marke (station_brand)", () => {
|
||||
/* Der Beleg vom 10.11.2025, wie er wirklich gespeichert ist: der Name traegt
|
||||
den BETREIBER, die Marke steht nur auf dem PDF. Seit dem 04.09.2026 liest
|
||||
sie shell_beleg_parser._marke aus dem Belegkopf, belege.marken_nachtragen
|
||||
traegt sie fuer Altbelege nach - und hier kommt sie an. */
|
||||
const NAME = "TC Sengül GmbH"
|
||||
const ADRESSE = "Nürnberger Str.74, 91522 Ansbach"
|
||||
|
||||
it("nimmt die gespeicherte Marke, wo der Name keine hergibt", () => {
|
||||
// Ohne sie stand der Einzelbeleg ohne Marke da, obwohl "Shell Station"
|
||||
// auf dem Beleg steht (Befund des Eigentuemers, 04.09.2026).
|
||||
expect(tankstelleZeilen(NAME, ADRESSE)).toEqual({
|
||||
oben: "Nürnberger Str. 74",
|
||||
unten: "91522 Ansbach",
|
||||
})
|
||||
expect(tankstelleZeilen(NAME, ADRESSE, "Shell")).toEqual({
|
||||
oben: "Shell, Nürnberger Str. 74",
|
||||
unten: "91522 Ansbach",
|
||||
})
|
||||
})
|
||||
|
||||
it("nimmt sie auch in der Uebersicht", () => {
|
||||
expect(tankstelleKurz(NAME, ADRESSE, "Shell")).toBe("Shell, Nürnberger Str., Ansbach")
|
||||
})
|
||||
|
||||
it("geht der Erkennung aus dem Namen vor", () => {
|
||||
// Die gespeicherte Angabe stammt vom Belegkopf, die andere ist geraten.
|
||||
expect(tankstelleTeile("Aral, Musterweg 1, Musterort", null, "Shell").marke).toBe("Shell")
|
||||
})
|
||||
|
||||
it("faellt auf die Erkennung zurueck, wo nichts gespeichert ist", () => {
|
||||
// Vorgaenge ohne Beleg und von Hand eingetragene Tankstellen haben das
|
||||
// Feld nie - dort bleibt es beim bisherigen Weg.
|
||||
expect(tankstelleKurz("Shell, Pascalstr. 8, Ingolstadt", null, null)).toBe(
|
||||
"Shell, Pascalstr., Ingolstadt",
|
||||
)
|
||||
expect(tankstelleKurz("Shell, SÖLDEN", null, undefined).startsWith("Shell")).toBe(true)
|
||||
})
|
||||
|
||||
it("behandelt eine leere gespeicherte Marke wie gar keine", () => {
|
||||
// marken_nachtragen schreibt null, wenn der Beleg keine Marke nennt.
|
||||
expect(tankstelleTeile("Shell, SÖLDEN", null, "").marke).toBe("Shell")
|
||||
expect(tankstelleTeile("Shell, SÖLDEN", null, " ").marke).toBe("Shell")
|
||||
})
|
||||
|
||||
it("gibt sie dem Kartendienst mit", () => {
|
||||
// Apple Karten und Google Maps treffen mit der Marke die Zapfsaeule statt
|
||||
// des Nachbargebaeudes - die Firmierung hilft dort nirgends.
|
||||
expect(tankstelleZiel(NAME, ADRESSE, "Shell")).toBe(
|
||||
"Shell, Nürnberger Str.74, 91522 Ansbach",
|
||||
)
|
||||
expect(kartendienstUrl(NAME, ADRESSE, null, "zeigen", "Shell")).toContain(
|
||||
encodeURIComponent("Shell, Nürnberger Str.74, 91522 Ansbach"),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Marke am Anfang eines Namensteils", () => {
|
||||
/* Am 04.09.2026 an der Musterseite aufgefallen: ein von Hand eingetragener
|
||||
Name ohne Komma rutschte komplett als "Ort" durch. */
|
||||
|
||||
it("laesst die Marke nicht zweimal erscheinen", () => {
|
||||
expect(tankstelleKurz("Shell München Ost", "Musterstraße 5")).toBe(
|
||||
"Shell, Musterstr., München Ost",
|
||||
)
|
||||
})
|
||||
|
||||
it("nimmt der Zeile die Marke ab, laesst den Ort stehen", () => {
|
||||
expect(ohneMarkeVorn("Shell München Ost")).toBe("München Ost")
|
||||
expect(ohneMarkeVorn("Shell, SÖLDEN")).toBe("SÖLDEN")
|
||||
expect(ohneMarkeVorn("Shell")).toBe("")
|
||||
})
|
||||
|
||||
it("laesst einen Teil in Ruhe, in dem die Marke nicht vorn steht", () => {
|
||||
// Dort ist nicht sicher, was Marke und was Ortsname ist.
|
||||
expect(ohneMarkeVorn("Autohof Shell Nord")).toBe("Autohof Shell Nord")
|
||||
expect(ohneMarkeVorn("Musterstraße 5")).toBe("Musterstraße 5")
|
||||
})
|
||||
|
||||
it("achtet auf die Wortgrenze", () => {
|
||||
// "Star" darf nicht in "Starnberg" treffen.
|
||||
expect(ohneMarkeVorn("Starnberg")).toBe("Starnberg")
|
||||
expect(ohneMarkeVorn("Sprintstraße 5")).toBe("Sprintstraße 5")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* Tankstelle eines Belegs: Anzeige in zwei Zeilen und der Weg in den
|
||||
* Kartendienst des Geräts.
|
||||
*
|
||||
* WARUM ZWEI ZEILEN
|
||||
* -----------------
|
||||
* Bis zum 04.09.2026 stand die Tankstelle als gewöhnliche Wertezeile rechts in
|
||||
* der schmalen Wertespalte: Name oben, die komplette Anschrift als Kleintext
|
||||
* darunter. Gemessen an der echten Kachel blieben dafür 120 px - "Hermann
|
||||
* Mogler Mineralölg. GmbH, Heidenheimerstr.6, 89551 Königsbronn" brach dort in
|
||||
* vier bis fünf Zeilen um. Die Aufteilung hier ist die vom Eigentümer
|
||||
* verlangte: erste Zeile Tankstelle und Straße, zweite Zeile der Ort.
|
||||
*
|
||||
* WIE DIE ANSCHRIFT GETEILT WIRD
|
||||
* ------------------------------
|
||||
* Der Belegparser setzt sie als `"<Straße>, <PLZ Ort>"` zusammen
|
||||
* (shell_beleg_parser.py). Geteilt wird deshalb am LETZTEN Komma: ein Name wie
|
||||
* "Str. 5, Gewerbepark, 85057 Ingolstadt" behält so seinen Zusatz bei der
|
||||
* Straße statt beim Ort. Ohne Komma bleibt alles in der ersten Zeile - lieber
|
||||
* ein zu langer erster Eintrag als ein erfundener Ort.
|
||||
*
|
||||
* KARTENDIENST STATT EINES FESTEN ANBIETERS
|
||||
* -----------------------------------------
|
||||
* "Klick auf die Tankstelle soll den Standard-Kartendienst öffnen" (Eigentümer,
|
||||
* 04.09.2026). Auf Apple-Geräten ist das Karten, sonst Google Maps - beides
|
||||
* über die jeweilige Web-Adresse, die das System an die installierte App
|
||||
* weiterreicht. Ein `geo:`-Verweis wäre der sauberere Weg, aber Safari kennt
|
||||
* ihn nicht, und ein Verweis, der auf dem Hauptgerät ins Leere läuft, ist
|
||||
* keiner.
|
||||
*/
|
||||
|
||||
export interface Koordinate {
|
||||
lat: number
|
||||
lon: number
|
||||
}
|
||||
|
||||
/* Kopfmarken des deutschsprachigen Marktes.
|
||||
|
||||
WORTGLEICH mit `_MARKEN` in custom_components/audi_dashboard/shell_beleg_parser.py
|
||||
(und mit MARKEN im Panel) - der Parser erkennt die Marke beim Einlesen eines
|
||||
Belegs, diese Liste erkennt sie nachträglich in dem, was gespeichert ist.
|
||||
Laufen die beiden auseinander, zeigt die App eine andere Marke als der
|
||||
Beleg. Reihenfolge zählt: "TotalEnergies" muss vor "Total" stehen. */
|
||||
const MARKEN = [
|
||||
"TotalEnergies", "Total", "Aral", "Shell", "Eni", "Agip", "Esso", "OMV",
|
||||
"Avia", "Turmöl", "Turmoel", "Orlen", "Tamoil", "Westfalen", "Allguth",
|
||||
"Classic", "Sprint", "Raiffeisen", "BayWa", "Elan", "HEM", "Star", "JET",
|
||||
"bft", "BP", "Q1",
|
||||
]
|
||||
|
||||
/**
|
||||
* Die Kopfmarke aus dem gespeicherten Stationsnamen - `null`, wenn keine
|
||||
* bekannte darin steht.
|
||||
*
|
||||
* WARUM ÜBERHAUPT NACHTRÄGLICH: `station_name` trägt je nach Alter des Belegs
|
||||
* Verschiedenes. Seit dem 16.08.2026 setzt der Parser "Marke, Straße, Ort"
|
||||
* zusammen ("Shell, SÖLDEN"); ältere Belege - und jeder ohne erkennbare
|
||||
* Marke - tragen dort die Firmierung des Betreibers ("A. Zrenner GmbH",
|
||||
* "Hermann Mogler Mineralölg. GmbH"). Die ist nach außen nicht sichtbar und
|
||||
* gehört deshalb nicht in die Anzeige (Vorgabe des Eigentümers, 04.09.2026);
|
||||
* sichtbar ist die Kopfmarke.
|
||||
*
|
||||
* Wortgrenzen sind Absicht: "Star" darf nicht in "Starnberg" treffen.
|
||||
*
|
||||
* NUR DER ZWEITE WEG: seit dem 04.09.2026 speichert die Integration die Marke
|
||||
* als eigenes Feld `station_brand` (shell_beleg_parser._marke, aus dem
|
||||
* Belegkopf), und fuer Altbelege traegt sie belege.marken_nachtragen() aus der
|
||||
* abgelegten Belegdatei nach. Wo dieses Feld etwas hergibt, gilt es - hier
|
||||
* geraten wird nur, wo es fehlt: bei Vorgaengen ohne Beleg, bei von Hand
|
||||
* eingetragenen Tankstellen und solange das Nachtragen nicht gelaufen ist.
|
||||
*/
|
||||
/**
|
||||
* Nimmt einem Namensteil die fuehrende Kopfmarke ab.
|
||||
*
|
||||
* WARUM NICHT NUR VERGLEICHEN: bis zum 04.09.2026 wurde ein Namensteil nur
|
||||
* dann uebersprungen, wenn er FUER SICH eine Marke ist ("Shell"). Ein von
|
||||
* Hand eingetragener Name wie "Shell München Ost" hat aber kein Komma - er
|
||||
* rutschte komplett durch und landete als ORT in der Zeile:
|
||||
*
|
||||
* "Shell München Ost" + "Musterstraße 5"
|
||||
* -> "Shell, Musterstr., Shell München Ost"
|
||||
*
|
||||
* Jetzt faellt die Marke vorn ab und der Rest bleibt der Ort. Steht die Marke
|
||||
* NICHT am Anfang ("Autohof Shell Nord"), bleibt der Teil unangetastet -
|
||||
* dort ist nicht sicher, was Marke und was Ortsname ist.
|
||||
*/
|
||||
export function ohneMarkeVorn(teil: string): string {
|
||||
const marke = markeErkennen(teil)
|
||||
if (!marke) return teil
|
||||
// Kein Maskieren noetig: `marke` stammt immer aus MARKEN, und dort steht
|
||||
// kein einziges Sonderzeichen der regulaeren Ausdruecke.
|
||||
const muster = new RegExp(`^${marke}\\b[\\s,]*`, "i")
|
||||
return muster.test(teil) ? teil.replace(muster, "").trim() : teil
|
||||
}
|
||||
|
||||
export function markeErkennen(name: string | null | undefined): string | null {
|
||||
const roh = (name ?? "").trim()
|
||||
if (!roh) return null
|
||||
for (const marke of MARKEN) {
|
||||
const muster = new RegExp(`\\b${marke.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i")
|
||||
if (muster.test(roh)) return marke
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/* Kennzeichen einer Firmierung. Ein Teil, der so etwas enthaelt, ist der
|
||||
BETREIBER und gehoert nach Vorgabe des Eigentuemers nicht in die Anzeige -
|
||||
"TC Sengül GmbH" oder "Hermann Mogler Mineralölg. GmbH" stehen auf keinem
|
||||
Preisschild und in keiner Karte. */
|
||||
const FIRMA = /\b(gmbh|mbh|ohg|kg|ag|e\.\s?k\.|inh\.|& co)\b/i
|
||||
|
||||
/* "91522 Ansbach" - vier Stellen wegen Oesterreich (der Belegparser hat
|
||||
denselben Fall, siehe shell_beleg_parser.py). */
|
||||
const PLZ_ORT = /^(\d{4,5})\s+(.+)$/
|
||||
|
||||
/* Strasse mit Hausnummer am Ende. Greedy bis zum letzten Nicht-Ziffernzeichen,
|
||||
damit auch die zusammengeschriebene Form der Belege trifft:
|
||||
"Nürnberger Str.74" -> "Nürnberger Str." + "74". */
|
||||
const STRASSE_NR = /^(.*\D)\s*(\d+\s*[a-zA-Z]?)$/
|
||||
|
||||
/**
|
||||
* "Nürnberger Straße" → "Nürnberger Str.", "Heidenheimerstraße" →
|
||||
* "Heidenheimerstr.". Vorgabe des Eigentuemers fuer die Uebersicht; die Belege
|
||||
* liefern beide Schreibweisen, je nach Kasse.
|
||||
*
|
||||
* Die Grossschreibung des Treffers entscheidet, ob "Str." oder "str." daraus
|
||||
* wird - sonst stuende mitten im Wort ein grosses S.
|
||||
*/
|
||||
export function strasseKuerzen(strasse: string): string {
|
||||
return strasse.replace(/stra(?:ß|ss)e\b\.?/gi, (treffer) =>
|
||||
treffer[0] === treffer[0]?.toUpperCase() ? "Str." : "str.",
|
||||
)
|
||||
}
|
||||
|
||||
export interface Tankstellenteile {
|
||||
/** Kopfmarke, sofern erkennbar. Nie die Firmierung des Betreibers. */
|
||||
marke: string
|
||||
/** Ohne Hausnummer, in der kurzen Schreibweise. */
|
||||
strasse: string
|
||||
hausnummer: string
|
||||
plz: string
|
||||
ort: string
|
||||
/** Der gespeicherte Name, unveraendert - der letzte Rueckfall. */
|
||||
roh: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Zerlegt, was ein Beleg ueber die Tankstelle hergibt.
|
||||
*
|
||||
* WARUM BEIDE FELDER GELESEN WERDEN
|
||||
* ---------------------------------
|
||||
* Der Bestand hat zwei Bauformen, am 04.09.2026 an den echten Datensaetzen
|
||||
* nachgesehen:
|
||||
*
|
||||
* bis 2025: station_name = "TC Sengül GmbH" (der Betreiber!)
|
||||
* station_address = "Nürnberger Str.74, 91522 Ansbach"
|
||||
* ab 08.2026: station_name = "Shell, Pascalstr. 8, Ingolstadt"
|
||||
* station_address = null
|
||||
*
|
||||
* Die Markenerkennung lief bis hierher nur ueber `station_name`. Bei den
|
||||
* aelteren Belegen steht die Marke dort gar nicht - deshalb blieb in der
|
||||
* Uebersicht die Firmierung stehen ("...GmbH", Nutzerbefund 04.09.2026),
|
||||
* waehrend die Anschrift daneben ungenutzt blieb. Gelesen werden deshalb
|
||||
* beide Felder: die Anschrift zuerst (sie ist die strukturierte Quelle), der
|
||||
* Name danach fuer Marke und alles, was die Anschrift nicht hergibt.
|
||||
*
|
||||
* `marke` ist die gespeicherte Angabe des Belegs (`station_brand`). Sie geht
|
||||
* vor, weil sie vom Belegkopf stammt und nicht aus dem Anzeigenamen geraten
|
||||
* ist - genau die Luecke, durch die der Beleg vom 10.11.2025 ohne Marke
|
||||
* dastand, obwohl "Shell Station" auf ihm steht.
|
||||
*/
|
||||
export function tankstelleTeile(
|
||||
name: string | null | undefined,
|
||||
adresse: string | null | undefined,
|
||||
marke?: string | null | undefined,
|
||||
): Tankstellenteile {
|
||||
const teile: Tankstellenteile = {
|
||||
marke: (marke ?? "").trim() || markeErkennen(name) || "",
|
||||
strasse: "",
|
||||
hausnummer: "",
|
||||
plz: "",
|
||||
ort: "",
|
||||
roh: (name ?? "").trim(),
|
||||
}
|
||||
|
||||
const quellen: string[] = []
|
||||
for (const stueck of (adresse ?? "").split(",")) quellen.push(stueck)
|
||||
for (const stueck of (name ?? "").split(",")) {
|
||||
// Die Marke faellt vorn ab - sie steht schon fest und taugt nie als Ort.
|
||||
// Bleibt nichts uebrig, war der Teil nur die Marke selbst.
|
||||
const t = ohneMarkeVorn(stueck.trim())
|
||||
if (t) quellen.push(t)
|
||||
}
|
||||
|
||||
for (const stueck of quellen) {
|
||||
const t = stueck.trim()
|
||||
if (!t || FIRMA.test(t)) continue
|
||||
|
||||
const plzOrt = PLZ_ORT.exec(t)
|
||||
if (plzOrt) {
|
||||
if (!teile.plz) teile.plz = plzOrt[1] ?? ""
|
||||
if (!teile.ort) teile.ort = (plzOrt[2] ?? "").trim()
|
||||
continue
|
||||
}
|
||||
|
||||
const mitNummer = STRASSE_NR.exec(t)
|
||||
if (mitNummer) {
|
||||
if (!teile.strasse) {
|
||||
teile.strasse = strasseKuerzen((mitNummer[1] ?? "").trim())
|
||||
teile.hausnummer = (mitNummer[2] ?? "").replace(/\s+/g, "")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Weder Postleitzahl noch Hausnummer: der Ort. Eine Strasse ohne Nummer
|
||||
// laesst sich davon nicht sicher unterscheiden - im Zweifel der Ort, weil
|
||||
// er in der Uebersicht die wichtigere Angabe ist.
|
||||
if (!teile.ort) teile.ort = t
|
||||
}
|
||||
|
||||
return teile
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Zeile der Uebersicht: **Marke, Strasse ohne Hausnummer, Ort ohne
|
||||
* Postleitzahl** (Vorgabe des Eigentuemers, 04.09.2026).
|
||||
*
|
||||
* Ergibt die Zerlegung gar nichts - etwa wenn nur eine Firmierung ohne
|
||||
* Anschrift gespeichert ist - bleibt der rohe Name stehen. Ihn dann
|
||||
* wegzulassen hiesse, eine bekannte Tankstelle "unbekannt" zu nennen.
|
||||
*/
|
||||
export function tankstelleKurz(
|
||||
name: string | null | undefined,
|
||||
adresse: string | null | undefined,
|
||||
marke?: string | null | undefined,
|
||||
): string {
|
||||
const t = tankstelleTeile(name, adresse, marke)
|
||||
const zeile = [t.marke, t.strasse, t.ort].filter(Boolean).join(", ")
|
||||
return zeile || t.roh || "unbekannt"
|
||||
}
|
||||
|
||||
/**
|
||||
* Die beiden Zeilen im Einzelbeleg (Vorgabe des Eigentuemers, 04.09.2026):
|
||||
* oben **Marke, Strasse mit Hausnummer**, unten **Postleitzahl und Ort**.
|
||||
*
|
||||
* Geschrieben wie eine Anschrift, also "Shell, Pascalstr. 8" und "85057
|
||||
* Ingolstadt" - das Komma trennt die Marke ab, zwischen Strasse und Nummer
|
||||
* und zwischen Postleitzahl und Ort steht keins.
|
||||
*
|
||||
* `oben` ist nie leer: gibt der Beleg nur einen Ort her, rueckt der nach oben,
|
||||
* und kennt er gar nichts, heisst es dort "unbekannt".
|
||||
*/
|
||||
export function tankstelleZeilen(
|
||||
name: string | null | undefined,
|
||||
adresse: string | null | undefined,
|
||||
marke?: string | null | undefined,
|
||||
): { oben: string; unten: string } {
|
||||
const t = tankstelleTeile(name, adresse, marke)
|
||||
const strasse = [t.strasse, t.hausnummer].filter(Boolean).join(" ")
|
||||
let oben = [t.marke, strasse].filter(Boolean).join(", ")
|
||||
let unten = [t.plz, t.ort].filter(Boolean).join(" ")
|
||||
if (!oben && !unten) return { oben: t.roh || "unbekannt", unten: "" }
|
||||
if (!oben) {
|
||||
oben = unten
|
||||
unten = ""
|
||||
}
|
||||
return { oben, unten }
|
||||
}
|
||||
|
||||
/**
|
||||
* Suchbegriffe für die Geokodierung, in der Reihenfolge, in der sie versucht
|
||||
* werden: erst die Anschrift, dann der Name.
|
||||
*
|
||||
* NICHT beides zusammen. Am 04.09.2026 gemessen: `"Pascalstr. 8, 85057
|
||||
* Ingolstadt"` findet Nominatim, `"A. Zrenner GmbH, Pascalstr. 8, 85057
|
||||
* Ingolstadt"` nicht — der Betreibername steht in keiner Karte und lässt die
|
||||
* Freitextsuche ins Leere laufen. Genau daran ist die erste Fassung dieser
|
||||
* Funktion gescheitert, sichtbar als „Keine Position zur Tankstelle" bei einem
|
||||
* Beleg, dessen Anschrift sich vorher hat auflösen lassen.
|
||||
*
|
||||
* Der Name allein ist trotzdem wertvoll: `"Shell, SÖLDEN"` findet Nominatim
|
||||
* (46.9756/11.0111) — deshalb der zweite Versuch statt gar keinem.
|
||||
*
|
||||
* Und als dritter Versuch der Name OHNE die führende Marke. Seit dem 16.08.2026
|
||||
* setzt der Belegparser den Namen als „Marke, Straße, Ort" zusammen; steht darin
|
||||
* keine Postleitzahl, kippt die Marke die Suche. Am 04.09.2026 gemessen:
|
||||
* `"Shell, Pascalstr. 8, Ingolstadt"` findet Nominatim NICHT,
|
||||
* `"Pascalstr. 8, Ingolstadt"` dagegen schon. Genau daran blieb die Karte auf
|
||||
* der realen Instanz leer, während dieselbe Tankstelle in der Testinstanz
|
||||
* (dort steht die Anschrift getrennt daneben) gefunden wurde.
|
||||
*/
|
||||
export function tankstelleKandidaten(
|
||||
name: string | null | undefined,
|
||||
adresse: string | null | undefined,
|
||||
): string[] {
|
||||
const n = (name ?? "").trim()
|
||||
const marke = markeErkennen(n)
|
||||
// Nur abschneiden, wenn die Marke wirklich vorn steht und noch etwas folgt.
|
||||
const ohneMarke =
|
||||
marke && n.toLowerCase().startsWith(marke.toLowerCase())
|
||||
? n.slice(marke.length).replace(/^[ ,]+/, "").trim()
|
||||
: ""
|
||||
const liste = [(adresse ?? "").trim(), n, ohneMarke].filter(Boolean)
|
||||
return [...new Set(liste)]
|
||||
}
|
||||
|
||||
/** Der erste Suchbegriff - leer heißt: der Beleg nennt gar nichts. */
|
||||
export function tankstelleSuchtext(
|
||||
name: string | null | undefined,
|
||||
adresse: string | null | undefined,
|
||||
): string {
|
||||
return tankstelleKandidaten(name, adresse)[0] ?? ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Ziel für den Kartendienst: Kopfmarke UND Anschrift. Apple Karten und
|
||||
* Google Maps kennen Tankstellenmarken und treffen damit die Zapfsäule statt
|
||||
* des Nachbargebäudes - beide sind darin deutlich besser als Nominatims
|
||||
* Freitextsuche. Die Firmierung des Betreibers hilft dagegen nirgends; ohne
|
||||
* erkennbare Marke geht die Anschrift allein hin.
|
||||
*/
|
||||
export function tankstelleZiel(
|
||||
name: string | null | undefined,
|
||||
adresse: string | null | undefined,
|
||||
marke?: string | null | undefined,
|
||||
): string {
|
||||
const a = (adresse ?? "").trim()
|
||||
if (!a) return (name ?? "").trim()
|
||||
return [(marke ?? "").trim() || markeErkennen(name) || "", a].filter(Boolean).join(", ")
|
||||
}
|
||||
|
||||
/** Apple-Gerät? Dort ist Karten der Standarddienst. */
|
||||
function apfelgeraet(): boolean {
|
||||
if (typeof navigator === "undefined") return false
|
||||
const kennung = `${navigator.userAgent || ""} ${navigator.platform || ""}`
|
||||
return /iPhone|iPad|iPod|Macintosh|Mac OS X/i.test(kennung)
|
||||
}
|
||||
|
||||
/**
|
||||
* "route" berechnet den Weg dorthin, "zeigen" setzt nur eine Nadel. Der
|
||||
* Tankstellen-Verweis im Einzelbeleg will das Zweite (Vorgabe des Eigentümers,
|
||||
* 04.09.2026): erst sehen, wo die Tankstelle liegt - eine Route erwartet dort
|
||||
* niemand, und sie ist auf dem Telefon nur einen weiteren Tipp entfernt.
|
||||
*/
|
||||
export type Kartenmodus = "route" | "zeigen"
|
||||
|
||||
/**
|
||||
* Verweis in den Kartendienst - `null`, wenn nichts bekannt ist, wohin.
|
||||
*
|
||||
* Mit Koordinaten wird auf sie gezielt und der Name nur als Beschriftung
|
||||
* mitgegeben; ohne sie muss die Anschrift als Suchtext genügen.
|
||||
*/
|
||||
export function kartendienstUrl(
|
||||
name: string | null | undefined,
|
||||
adresse: string | null | undefined,
|
||||
pos?: Koordinate | null,
|
||||
modus: Kartenmodus = "route",
|
||||
marke?: string | null | undefined,
|
||||
): string | null {
|
||||
const beschriftung = (name ?? "").trim()
|
||||
const ziel = pos ? `${pos.lat},${pos.lon}` : tankstelleZiel(name, adresse, marke)
|
||||
if (!ziel) return null
|
||||
if (apfelgeraet()) {
|
||||
if (modus === "zeigen") {
|
||||
// Mit Koordinaten der Punkt selbst, sonst der Suchtext - beides zeigt
|
||||
// Karten nur an, ohne eine Route zu berechnen.
|
||||
return pos
|
||||
? `https://maps.apple.com/?ll=${ziel}&q=${encodeURIComponent(beschriftung || "Ziel")}`
|
||||
: `https://maps.apple.com/?q=${encodeURIComponent(ziel)}`
|
||||
}
|
||||
const q = pos && beschriftung ? `&q=${encodeURIComponent(beschriftung)}` : ""
|
||||
return `https://maps.apple.com/?daddr=${encodeURIComponent(ziel)}${q}`
|
||||
}
|
||||
return modus === "zeigen"
|
||||
? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(ziel)}`
|
||||
: `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(ziel)}`
|
||||
}
|
||||
|
||||
/** Wie oben, aber für einen reinen Punkt (Fahrzeugstandort, Autohaus). */
|
||||
export function kartendienstUrlZuPunkt(pos: Koordinate | null | undefined): string | null {
|
||||
if (!pos) return null
|
||||
return kartendienstUrl(null, null, pos)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Beleg-PDF aus der Zwischenablage oder einer Dateiliste.
|
||||
*
|
||||
* Übernommen aus dem Panel (`belegAusZwischenablage()`, `istPdf()`,
|
||||
* `zwischenablageKnopfSinnvoll()` in audi-dashboard-app.js) — dort gibt es
|
||||
* diesen Weg seit längerem, in der App fehlte er ganz: der Beleg-Knopf öffnete
|
||||
* direkt die Dateiauswahl, und ein aus der Mail kopiertes PDF ließ sich
|
||||
* überhaupt nicht einfügen (gemeldet vom Eigentümer, 2026-08-31).
|
||||
*
|
||||
* WARUM DER KNOPF NUR AUF TIPPGERÄTEN ERSCHEINT
|
||||
* ---------------------------------------------
|
||||
* `navigator.clipboard.read()` gibt aus Datenschutzgründen nur Text, HTML und
|
||||
* Bilder heraus. Eine im Explorer oder Finder kopierte Datei taucht dort nie
|
||||
* auf — am Rechner scheitert der Weg also zwangsläufig, und ein Knopf, der
|
||||
* immer fehlschlägt, ist schlimmer als kein Knopf. Dort bleiben Ziehen und
|
||||
* Dateiauswahl.
|
||||
*
|
||||
* AUF DEM TELEFON LÄUFT ER SEIT DEM 02.09.2026 NATIV
|
||||
* --------------------------------------------------
|
||||
* Dieselbe Grenze der Web-Schnittstelle traf nämlich auch iOS: ein aus Mail
|
||||
* kopiertes PDF ist über `navigator.clipboard.read()` NIE zu bekommen, der
|
||||
* Knopf endete dort zwangsläufig mit „In der Zwischenablage liegt kein PDF"
|
||||
* (gemeldet vom Eigentümer). `UIPasteboard` kennt die Grenze nicht — deshalb
|
||||
* geht die Anfrage auf dem Gerät über ein kleines eigenes Plugin
|
||||
* (native/BelegZwischenablage.swift). Die Einfügen-Rückfrage des Systems
|
||||
* erscheint dabei genauso wie im Web.
|
||||
*/
|
||||
|
||||
import { Capacitor, registerPlugin } from "@capacitor/core"
|
||||
|
||||
/** Nicht nur auf den MIME-Typ verlassen: eine aus dem Explorer/Finder kopierte
|
||||
oder gezogene Datei kommt je nach Browser und Quelle mit leerem `type` an. */
|
||||
export function istPdf(datei: File | null | undefined): boolean {
|
||||
if (!datei) return false
|
||||
return datei.type === "application/pdf" || /\.pdf$/i.test(datei.name || "")
|
||||
}
|
||||
|
||||
/** Nimmt das erste PDF aus einer DataTransfer-/Dateiliste. */
|
||||
export function pdfAusDateiliste(dateien: FileList | File[] | null | undefined): File | null {
|
||||
return [...(dateien ?? [])].find(istPdf) ?? null
|
||||
}
|
||||
|
||||
export class ZwischenablageFehler extends Error {}
|
||||
|
||||
interface BelegZwischenablage {
|
||||
/** Leeres Ergebnis heißt „nichts Passendes kopiert" - kein Fehler. */
|
||||
pdfHolen(): Promise<{ base64?: string; name?: string }>
|
||||
}
|
||||
|
||||
/** Muss zu `jsName` in BelegZwischenablage.swift passen. */
|
||||
const Nativ = registerPlugin<BelegZwischenablage>("BelegZwischenablage")
|
||||
|
||||
function base64ZuDatei(base64: string, name: string): File {
|
||||
const roh = atob(base64)
|
||||
const bytes = new Uint8Array(roh.length)
|
||||
for (let i = 0; i < roh.length; i++) bytes[i] = roh.charCodeAt(i)
|
||||
return new File([bytes], name, { type: "application/pdf" })
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt ein PDF aus der Zwischenablage.
|
||||
*
|
||||
* Wirft mit einem Text, der zur jeweiligen Ursache die richtige Abhilfe nennt —
|
||||
* "bitte Datei auswählen" hilft nicht weiter, wenn schlicht kein PDF kopiert
|
||||
* wurde, und "erneut kopieren" nicht, wenn der Browser die Zwischenablage gar
|
||||
* nicht herausgibt.
|
||||
*/
|
||||
export async function pdfAusZwischenablage(): Promise<File> {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
/* Meldet sich der native Teil nicht bei der Bruecke, hilft nur die
|
||||
Dateiauswahl - der Web-Weg kann ein PDF grundsaetzlich nicht liefern
|
||||
(Begruendung im Modulkopf).
|
||||
|
||||
Bis zum 04.09.2026 stand hier die Vermutung, die App sei nur ueber die
|
||||
Aktualisierung gewachsen. Sie war falsch: der Eigentuemer hatte frisch
|
||||
installiert, und BelegZwischenablagePlugin lag nachweislich im
|
||||
Programm. Capacitor 8 sucht Plugin-Klassen aber nicht im Programm,
|
||||
sondern registriert ausschliesslich, was in packageClassList der
|
||||
capacitor.config.json steht - und `cap sync` traegt dort nur
|
||||
npm-Pakete ein, keine app-eigenen Plugins. Behoben in
|
||||
scripts/ios-teilen-einrichten.mjs; hier steht deshalb nur noch die
|
||||
Tatsache und nicht mehr eine geratene Ursache. */
|
||||
if (!Capacitor.isPluginAvailable("BelegZwischenablage")) {
|
||||
throw new ZwischenablageFehler(
|
||||
'Diese App-Fassung kann die Zwischenablage nicht abfragen - bitte ' +
|
||||
'"Datei auswählen".',
|
||||
)
|
||||
}
|
||||
let ergebnis: { base64?: string; name?: string }
|
||||
try {
|
||||
ergebnis = await Nativ.pdfHolen()
|
||||
} catch {
|
||||
// Abgelehnte Einfügen-Rückfrage des Systems.
|
||||
throw new ZwischenablageFehler(
|
||||
'Kein Zugriff auf die Zwischenablage - bitte "Datei auswählen".',
|
||||
)
|
||||
}
|
||||
if (!ergebnis?.base64) {
|
||||
throw new ZwischenablageFehler(
|
||||
'In der Zwischenablage liegt kein PDF - bitte den Beleg erneut kopieren oder "Datei auswählen".',
|
||||
)
|
||||
}
|
||||
return base64ZuDatei(ergebnis.base64, ergebnis.name || "beleg.pdf")
|
||||
}
|
||||
|
||||
if (!navigator.clipboard?.read) {
|
||||
throw new ZwischenablageFehler(
|
||||
'Dieser Browser gibt die Zwischenablage nicht heraus - bitte "Datei auswählen".',
|
||||
)
|
||||
}
|
||||
let eintraege: ClipboardItems
|
||||
try {
|
||||
eintraege = await navigator.clipboard.read()
|
||||
} catch {
|
||||
// Verweigerte Freigabe, abgebrochene System-Abfrage, unsicherer Ursprung.
|
||||
throw new ZwischenablageFehler('Kein Zugriff auf die Zwischenablage - bitte "Datei auswählen".')
|
||||
}
|
||||
for (const eintrag of eintraege) {
|
||||
const typ = eintrag.types.find((t) => t === "application/pdf")
|
||||
if (typ) {
|
||||
const blob = await eintrag.getType(typ)
|
||||
// Ein Name muss sein: das Backend legt den Beleg unter ihm ab.
|
||||
return new File([blob], "beleg.pdf", { type: "application/pdf" })
|
||||
}
|
||||
}
|
||||
throw new ZwischenablageFehler(
|
||||
'In der Zwischenablage liegt kein PDF - bitte den Beleg erneut kopieren oder "Datei auswählen".',
|
||||
)
|
||||
}
|
||||
|
||||
/** Tippgerät? Nur dort wird der Einfügen-Knopf angeboten (Begründung oben). */
|
||||
export function zwischenablageKnopfSinnvoll(): boolean {
|
||||
return typeof window !== "undefined" && !!window.matchMedia?.("(pointer: coarse)").matches
|
||||
}
|
||||
@@ -2,7 +2,20 @@ 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"
|
||||
import {
|
||||
datum,
|
||||
datumLang,
|
||||
datumZeit,
|
||||
dauer,
|
||||
de,
|
||||
deOderStrich,
|
||||
eur,
|
||||
isoTag,
|
||||
monatJahr,
|
||||
schnittpreis,
|
||||
summe,
|
||||
uhrzeit,
|
||||
} from "./format"
|
||||
|
||||
describe("Zahlen im deutschen Format", () => {
|
||||
it("setzt Punkt als Tausender- und Komma als Dezimaltrennzeichen", () => {
|
||||
@@ -18,22 +31,22 @@ describe("Zahlen im deutschen Format", () => {
|
||||
|
||||
it("unterscheidet mit deOderStrich zwischen null und unbekannt", () => {
|
||||
expect(deOderStrich(0)).toBe("0")
|
||||
expect(deOderStrich(null)).toBe("—")
|
||||
expect(deOderStrich(null)).toBe("–")
|
||||
})
|
||||
})
|
||||
|
||||
describe("dauer", () => {
|
||||
it("zeigt unter einer Stunde nur Minuten", () => {
|
||||
expect(dauer(2700)).toBe("45 min")
|
||||
expect(dauer(2700)).toBe("45 Min.")
|
||||
})
|
||||
|
||||
it("zeigt darüber Stunden und Minuten zweistellig", () => {
|
||||
expect(dauer(8040)).toBe("2 h 14 min")
|
||||
expect(dauer(8040)).toBe("2 h 14 Min.")
|
||||
})
|
||||
|
||||
it("zeigt einen Strich statt „0 min", () => {
|
||||
expect(dauer(0)).toBe("—")
|
||||
expect(dauer(null)).toBe("—")
|
||||
expect(dauer(0)).toBe("–")
|
||||
expect(dauer(null)).toBe("–")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -101,3 +114,93 @@ describe("Kalenderdatei", () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Datumsangaben aus dem Bestand", () => {
|
||||
/* Alle Faelle am 04.09.2026 an der laufenden Instanz gemessen - vorher
|
||||
vertauschte `new Date()` Tag und Monat, oder verwarf den Wert ganz. */
|
||||
|
||||
it("liest deutsche Tagesdaten richtig herum", () => {
|
||||
// Der gemeldete Fall: die Erstzulassung stand als 03.01.2025 da.
|
||||
expect(datum("01.03.2025")).toBe("01.03.2025")
|
||||
expect(datum("12.11.2024")).toBe("12.11.2024")
|
||||
expect(datum("1.3.2025")).toBe("01.03.2025")
|
||||
})
|
||||
|
||||
it("verliert Tage ab dem 13. nicht mehr", () => {
|
||||
// Die waren vorher ungueltig und verschwanden als "–".
|
||||
expect(datum("25.03.2025")).toBe("25.03.2025")
|
||||
expect(datum("31.12.2023")).toBe("31.12.2023")
|
||||
})
|
||||
|
||||
it("zeigt monatsgenaue Angaben als Monat, nicht als erfundenen Tag", () => {
|
||||
// Die Hauptuntersuchung steht als "MM/JJJJ" im Profil (so verlangt es das
|
||||
// Einrichtungsfeld). Vorher: "–". Ein "01.08.2026" waere eine Erfindung.
|
||||
expect(datum("08/2026")).toBe("August 2026")
|
||||
expect(datum("08.2026")).toBe("August 2026")
|
||||
expect(datum("2026-08")).toBe("August 2026")
|
||||
expect(datumLang("08/2026")).toBe("August 2026")
|
||||
})
|
||||
|
||||
it("gibt einer monatsgenauen Angabe keine Uhrzeit", () => {
|
||||
// 00:00 waere erfunden.
|
||||
expect(uhrzeit("08/2026")).toBe("–")
|
||||
expect(datumZeit("08/2026")).toBe("August 2026")
|
||||
})
|
||||
|
||||
it("laesst ISO vom Backend unveraendert richtig", () => {
|
||||
expect(datum("2026-08-14")).toBe("14.08.2026")
|
||||
expect(datum("2024-09-25T15:47:00")).toBe("25.09.2024")
|
||||
expect(datumZeit("2024-09-25T15:47:00")).toBe("25.09.2024, 15:47 Uhr")
|
||||
})
|
||||
|
||||
it("weist unmoegliche Daten ab, statt still ein anderes zu zeigen", () => {
|
||||
// new Date(2025,1,31) waere in JavaScript der 3. Maerz.
|
||||
expect(datum("31.02.2025")).toBe("–")
|
||||
expect(datum("32.01.2025")).toBe("–")
|
||||
expect(datum("00.01.2025")).toBe("–")
|
||||
expect(datum("01.13.2025")).toBe("–")
|
||||
})
|
||||
|
||||
it("bleibt bei Unbekanntem beim Strich", () => {
|
||||
expect(datum(null)).toBe("–")
|
||||
expect(datum("")).toBe("–")
|
||||
expect(datum("demnaechst")).toBe("–")
|
||||
expect(datum("01/03/2025")).toBe("–")
|
||||
})
|
||||
|
||||
it("nimmt Date und Zahl weiterhin an", () => {
|
||||
expect(datum(new Date(2025, 2, 1))).toBe("01.03.2025")
|
||||
expect(datum(new Date(2025, 2, 1).getTime())).toBe("01.03.2025")
|
||||
})
|
||||
|
||||
it("monatJahr kann beide Genauigkeiten", () => {
|
||||
expect(monatJahr("2026-08-14")).toBe("August 2026")
|
||||
expect(monatJahr("08/2026")).toBe("August 2026")
|
||||
})
|
||||
})
|
||||
|
||||
/* Der Eigentümer hat die Erstzulassung am 05.09.2026 „mit Punkt oder Strich"
|
||||
eingetragen und danach ein leeres HU-Feld vorgefunden. Die eigentliche
|
||||
Ursache lag im Speicherweg (Einstellungen.tsx), aber eine still verworfene
|
||||
Eingabe soll auch dann nicht entstehen, wenn jemand Bindestriche tippt. */
|
||||
describe("Datum mit Bindestrich statt Punkt", () => {
|
||||
it("liest 15-06-2024 wie 15.06.2024", () => {
|
||||
expect(datum("15-06-2024")).toBe(datum("15.06.2024"))
|
||||
expect(datum("15-06-2024")).toBe("15.06.2024")
|
||||
})
|
||||
|
||||
it("nimmt auch die einstellige Schreibweise", () => {
|
||||
expect(datum("1-3-2025")).toBe("01.03.2025")
|
||||
})
|
||||
|
||||
it("weist ein unmögliches Datum weiterhin ab, egal mit welchem Trenner", () => {
|
||||
// new Date(2025, 1, 31) wäre der 3. März - das darf kein Datum ergeben.
|
||||
expect(datum("31-02-2025")).toBe("–")
|
||||
expect(datum("31.02.2025")).toBe("–")
|
||||
})
|
||||
|
||||
it("verwechselt die ISO-Schreibweise nicht damit", () => {
|
||||
// 2025-03-01 ist Jahr-Monat-Tag, nicht Tag-Monat-Jahr.
|
||||
expect(datum("2025-03-01")).toBe("01.03.2025")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,9 +14,44 @@ export function eur(wert: number | null | undefined): string {
|
||||
return de(wert, 2)
|
||||
}
|
||||
|
||||
/** Wie de(), aber „—" statt „0" wenn nichts bekannt ist. */
|
||||
/* Fehlende Werte zeigt das Panel durchgängig als Halbgeviertstrich „–", nicht
|
||||
als Geviertstrich „—" (30 Fundstellen gegen eine). Hier stand app-weit der
|
||||
längere Strich — sichtbar breiter, sobald beide Anwendungen nebeneinander
|
||||
liegen. */
|
||||
|
||||
/** Wie de(), aber „–" statt „0" wenn nichts bekannt ist. */
|
||||
/** Eine gefahrene Strecke in der Staffelung, die der Eigentümer am 01.09.2026
|
||||
* festgelegt hat:
|
||||
*
|
||||
* unter 1 km -> Meter, ganzzahlig ("400 m", "999 m")
|
||||
* bis 99,9 km -> eine Nachkommastelle ("1,0 km", "6,9 km")
|
||||
* ab 100 km -> ganze Kilometer ("104 km")
|
||||
*
|
||||
* Seit der GNSS-Verfeinerung messen wir auf etwa zehn Meter genau; ohne
|
||||
* Nachkommastelle wurde davon nichts sichtbar (aus 6,896 km wurde "7 km").
|
||||
* Umgekehrt wäre eine Nachkommastelle bei dreistelligen Summen nur Unruhe.
|
||||
*
|
||||
* Gestaffelt wird nach dem GERUNDETEN Wert: 0,9996 km sind gerundet 1000 m
|
||||
* und gehören damit schon in die km-Stufe, sonst stünde dort "1.000 m".
|
||||
*
|
||||
* Wortgleich mit streckeTeile()/streckeText() im Panel. */
|
||||
export function streckeTeile(km: number | null | undefined): { wert: string; einheit: string } | null {
|
||||
const n = Number(km)
|
||||
if (km === null || km === undefined || !Number.isFinite(n)) return null
|
||||
const meter = Math.round(n * 1000)
|
||||
if (Math.abs(meter) < 1000) return { wert: de(meter), einheit: "m" }
|
||||
const fein = Math.round(n * 10) / 10
|
||||
if (Math.abs(fein) < 100) return { wert: de(fein, 1), einheit: "km" }
|
||||
return { wert: de(Math.round(n)), einheit: "km" }
|
||||
}
|
||||
|
||||
export function streckeText(km: number | null | undefined, ersatz = "–"): string {
|
||||
const t = streckeTeile(km)
|
||||
return t ? `${t.wert} ${t.einheit}` : ersatz
|
||||
}
|
||||
|
||||
export function deOderStrich(wert: number | null | undefined, nachkomma = 0): string {
|
||||
return wert === null || wert === undefined ? "—" : de(wert, nachkomma)
|
||||
return wert === null || wert === undefined ? "–" : de(wert, nachkomma)
|
||||
}
|
||||
|
||||
/** Summe über eine Liste. */
|
||||
@@ -53,45 +88,172 @@ const UHRZEIT = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-d
|
||||
|
||||
const MONAT_JAHR = new Intl.DateTimeFormat("de-DE", { month: "long", year: "numeric" })
|
||||
|
||||
function alsDatum(wert: string | number | Date | null | undefined): Date | null {
|
||||
/**
|
||||
* Datumsangaben aus dem Bestand kommen in DREI Formen - deshalb reicht
|
||||
* `new Date(wert)` hier nicht.
|
||||
*
|
||||
* WAS AM 04.09.2026 GEMESSEN WURDE
|
||||
* --------------------------------
|
||||
* `new Date()` liest einen nicht-ISO-Text nach amerikanischer Lesart, also
|
||||
* Monat vor Tag. Fuer deutsche Eingaben heisst das:
|
||||
*
|
||||
* "01.03.2025" (1. Maerz) -> 3. Januar 2025 - Tag und Monat vertauscht
|
||||
* "12.11.2024" -> 11. Dezember - dito
|
||||
* "25.03.2025" -> ungueltig - Wert verschwand als "-"
|
||||
* "08/2026" (Hauptunters.) -> ungueltig - dito
|
||||
*
|
||||
* Tage bis 12 wurden also STILL vertauscht, ab Tag 13 fiel der Wert ganz weg.
|
||||
* Sichtbar war es an der Erstzulassung: "Mein Audi" zeigte den Rohwert
|
||||
* richtig, "Identitaet und Technik" formatierte ihn und drehte ihn dabei um -
|
||||
* derselbe Wert, zwei Ergebnisse. Und die Hauptuntersuchung stand als "-" da,
|
||||
* obwohl "08/2026" gespeichert war.
|
||||
*
|
||||
* WARUM EIGENE MUSTER STATT EINER BIBLIOTHEK
|
||||
* ------------------------------------------
|
||||
* Es sind genau drei Formen, alle aus diesem Projekt: ISO vom Backend,
|
||||
* "TT.MM.JJJJ" aus den Eingabefeldern, "MM/JJJJ" fuer die Hauptuntersuchung
|
||||
* (so verlangt es das Einrichtungsfeld, siehe Panel `einErstzulassung`).
|
||||
* Erkannt wird nur, was hier wirklich vorkommt - alles andere bleibt `null`
|
||||
* und damit "-". Lieber kein Datum als ein erfundenes.
|
||||
*
|
||||
* MONATSGENAU IST EINE EIGENE GENAUIGKEIT
|
||||
* ---------------------------------------
|
||||
* "08/2026" nennt keinen Tag. Daraus den 1. August zu machen und
|
||||
* "01.08.2026" anzuzeigen waere eine Erfindung - deshalb traegt das Ergebnis
|
||||
* seine Genauigkeit mit sich, und `datum()` zeigt dann "August 2026".
|
||||
*/
|
||||
type Genauigkeit = "tag" | "monat"
|
||||
interface Zeitpunkt {
|
||||
zeit: Date
|
||||
genau: Genauigkeit
|
||||
}
|
||||
|
||||
/* ISO vom Backend, mit oder ohne Uhrzeit - das kann `new Date` selbst, und
|
||||
zwar nach Norm statt nach Gutduenken. */
|
||||
const ISO = /^\d{4}-\d{2}-\d{2}([T ]|$)/
|
||||
/* "1.3.2025" wie "01.03.2025". */
|
||||
// Bindestrich ist mitgemeint: der Eigentuemer hat am 05.09.2026 "mit Punkt
|
||||
// oder Strich" eingetragen. Eine still verworfene Eingabe ist der schlechtere
|
||||
// Ausgang - der Platzhalter nennt trotzdem TT.MM.JJJJ als die gemeinte Form.
|
||||
const DE_TAG = /^(\d{1,2})[.\-](\d{1,2})[.\-](\d{4})$/
|
||||
/* "08/2026" und "08.2026" - Monat und Jahr, ohne Tag. */
|
||||
const DE_MONAT = /^(\d{1,2})[./](\d{4})$/
|
||||
/* "2026-08" - dieselbe Genauigkeit, in ISO-Schreibweise. */
|
||||
const ISO_MONAT = /^(\d{4})-(\d{1,2})$/
|
||||
|
||||
/**
|
||||
* Baut das Datum und weist die Rollover-Falle ab: `new Date(2025, 1, 31)` ist
|
||||
* in JavaScript der 3. Maerz, nicht der 31. Februar. Ein unmoegliches Datum
|
||||
* soll "-" ergeben, nicht klammheimlich ein anderes.
|
||||
*/
|
||||
function bauen(jahr: number, monat: number, tag: number): Date | null {
|
||||
if (monat < 1 || monat > 12 || tag < 1 || tag > 31) return null
|
||||
const d = new Date(jahr, monat - 1, tag)
|
||||
if (d.getFullYear() !== jahr || d.getMonth() !== monat - 1 || d.getDate() !== tag) return null
|
||||
return d
|
||||
}
|
||||
|
||||
/** Wortgleich mit datumAusText() im Panel. Exportiert, weil die Ableitung
|
||||
der Hauptuntersuchung dieselbe Genauigkeit braucht wie die Anzeige: aus
|
||||
einer Erstzulassung "03/2025" darf kein Termin mit Tag entstehen. */
|
||||
export function alsZeitpunkt(wert: string | number | Date | null | undefined): Zeitpunkt | 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
|
||||
if (wert instanceof Date) return Number.isNaN(wert.getTime()) ? null : { zeit: wert, genau: "tag" }
|
||||
if (typeof wert === "number") {
|
||||
const d = new Date(wert)
|
||||
return Number.isNaN(d.getTime()) ? null : { zeit: d, genau: "tag" }
|
||||
}
|
||||
|
||||
const text = wert.trim()
|
||||
if (!text) return null
|
||||
|
||||
if (ISO.test(text)) {
|
||||
const d = new Date(text)
|
||||
return Number.isNaN(d.getTime()) ? null : { zeit: d, genau: "tag" }
|
||||
}
|
||||
|
||||
const tag = DE_TAG.exec(text)
|
||||
if (tag) {
|
||||
const d = bauen(Number(tag[3]), Number(tag[2]), Number(tag[1]))
|
||||
return d ? { zeit: d, genau: "tag" } : null
|
||||
}
|
||||
|
||||
const monat = DE_MONAT.exec(text)
|
||||
if (monat) {
|
||||
const d = bauen(Number(monat[2]), Number(monat[1]), 1)
|
||||
return d ? { zeit: d, genau: "monat" } : null
|
||||
}
|
||||
|
||||
const isoMonat = ISO_MONAT.exec(text)
|
||||
if (isoMonat) {
|
||||
const d = bauen(Number(isoMonat[1]), Number(isoMonat[2]), 1)
|
||||
return d ? { zeit: d, genau: "monat" } : null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function alsDatum(wert: string | number | Date | null | undefined): Date | null {
|
||||
return alsZeitpunkt(wert)?.zeit ?? null
|
||||
}
|
||||
|
||||
export function datum(wert: string | number | Date | null | undefined): string {
|
||||
const d = alsDatum(wert)
|
||||
return d ? DATUM_KURZ.format(d) : "—"
|
||||
const z = alsZeitpunkt(wert)
|
||||
if (!z) return "–"
|
||||
return z.genau === "monat" ? MONAT_JAHR.format(z.zeit) : DATUM_KURZ.format(z.zeit)
|
||||
}
|
||||
|
||||
export function datumLang(wert: string | number | Date | null | undefined): string {
|
||||
const d = alsDatum(wert)
|
||||
return d ? DATUM_LANG.format(d) : "—"
|
||||
const z = alsZeitpunkt(wert)
|
||||
if (!z) return "–"
|
||||
return z.genau === "monat" ? MONAT_JAHR.format(z.zeit) : DATUM_LANG.format(z.zeit)
|
||||
}
|
||||
|
||||
export function uhrzeit(wert: string | number | Date | null | undefined): string {
|
||||
const d = alsDatum(wert)
|
||||
return d ? UHRZEIT.format(d) : "—"
|
||||
const z = alsZeitpunkt(wert)
|
||||
// Eine monatsgenaue Angabe hat keine Uhrzeit - 00:00 waere erfunden.
|
||||
return z && z.genau === "tag" ? UHRZEIT.format(z.zeit) : "–"
|
||||
}
|
||||
|
||||
export function datumZeit(wert: string | number | Date | null | undefined): string {
|
||||
const d = alsDatum(wert)
|
||||
return d ? `${DATUM_KURZ.format(d)}, ${UHRZEIT.format(d)} Uhr` : "—"
|
||||
const z = alsZeitpunkt(wert)
|
||||
if (!z) return "–"
|
||||
if (z.genau === "monat") return MONAT_JAHR.format(z.zeit)
|
||||
return `${DATUM_KURZ.format(z.zeit)}, ${UHRZEIT.format(z.zeit)} Uhr`
|
||||
}
|
||||
|
||||
export function monatJahr(wert: string | number | Date | null | undefined): string {
|
||||
const d = alsDatum(wert)
|
||||
return d ? MONAT_JAHR.format(d) : "—"
|
||||
const z = alsZeitpunkt(wert)
|
||||
return z ? MONAT_JAHR.format(z.zeit) : "–"
|
||||
}
|
||||
|
||||
/** Dauer in Sekunden als „2 h 14 min" bzw. „14 min". */
|
||||
export function dauer(sekunden: number | null | undefined): string {
|
||||
if (!sekunden || sekunden <= 0) return "—"
|
||||
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`
|
||||
// "Min." statt "min": deutsche Abkuerzung, wie sie die Oberflaeche sonst
|
||||
// auch verwendet ("Geparkt seit 2 Tg. 16 Std. 12 Min.").
|
||||
if (stunden === 0) return `${minuten} Min.`
|
||||
return `${stunden} h ${String(minuten).padStart(2, "0")} Min.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Alter des letzten Datenstands als „vor 3 Std." — wortgleich mit
|
||||
* `standAlterText()` im Panel, das diesen Text in der Kopfzeile der Übersicht
|
||||
* neben dem Aktualisieren-Symbol zeigt.
|
||||
*/
|
||||
export function standAlter(wert: string | number | Date | null | undefined): string {
|
||||
const d = alsDatum(wert)
|
||||
if (!d) return "unbekannt"
|
||||
const sekunden = Math.max(0, Math.floor((Date.now() - d.getTime()) / 1000))
|
||||
if (sekunden < 60) return `vor ${sekunden} Sek.`
|
||||
const minuten = Math.floor(sekunden / 60)
|
||||
if (minuten < 60) return `vor ${minuten} Min.`
|
||||
const stunden = Math.floor(minuten / 60)
|
||||
if (stunden < 24) return `vor ${stunden} Std.`
|
||||
return `vor ${Math.floor(stunden / 24)} Tage`
|
||||
}
|
||||
|
||||
/** ISO-Tagesdatum (YYYY-MM-DD) für Vergleiche und Eingabefelder. */
|
||||
@@ -101,3 +263,14 @@ export function isoTag(wert: string | number | Date | null | undefined = new Dat
|
||||
const versatz = d.getTimezoneOffset() * 60_000
|
||||
return new Date(d.getTime() - versatz).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/**
|
||||
* Datum und Uhrzeit mit „ · " getrennt — so setzt das Panel den Zeitstempel in
|
||||
* der Batterie-Messwertliste (`dedat(...) + " · " + toLocaleTimeString(...)`).
|
||||
* `datumZeit()` daneben bleibt die Komma-Schreibweise für die Stellen, die sie
|
||||
* schon benutzen.
|
||||
*/
|
||||
export function datumUndZeit(wert: string | number | Date | null | undefined): string {
|
||||
const d = alsDatum(wert)
|
||||
return d ? `${DATUM_KURZ.format(d)} · ${UHRZEIT.format(d)} Uhr` : "–"
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import "./stile/grundlage.css"
|
||||
import "./stile/audi-schrift.css"
|
||||
import { nativeAblageEinrichten } from "./api"
|
||||
import { App } from "./App"
|
||||
import { ErrorGrenze } from "./ErrorGrenze"
|
||||
|
||||
// In der nativen Hülle den Token in den Schlüsselbund legen statt in
|
||||
// localStorage. Ohne Hülle passiert hier nichts.
|
||||
@@ -16,6 +17,8 @@ if (!wurzel) throw new Error("Wurzelelement #wurzel fehlt in index.html")
|
||||
|
||||
createRoot(wurzel).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<ErrorGrenze>
|
||||
<App />
|
||||
</ErrorGrenze>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -17,12 +17,14 @@ export type SeitenName =
|
||||
// Unterseiten von "Mein Audi"
|
||||
| "ident"
|
||||
| "battverlauf"
|
||||
| "battliste"
|
||||
| "service"
|
||||
| "werkstatt"
|
||||
| "sbuch"
|
||||
| "reifen"
|
||||
| "vers"
|
||||
| "beitrag"
|
||||
| "vertrag"
|
||||
| "vertragsdetails"
|
||||
| "schutz"
|
||||
| "notruf"
|
||||
@@ -32,6 +34,8 @@ export type SeitenName =
|
||||
| "fill"
|
||||
// Sonstige
|
||||
| "sicherheit"
|
||||
| "tuerenklappen"
|
||||
| "standort"
|
||||
| "live"
|
||||
| "einst"
|
||||
|
||||
@@ -52,8 +56,10 @@ export const ZURUECK: Partial<Record<SeitenName, SeitenName>> = {
|
||||
reifen: "audi",
|
||||
ident: "audi",
|
||||
battverlauf: "audi",
|
||||
battliste: "battverlauf",
|
||||
vers: "audi",
|
||||
beitrag: "vers",
|
||||
vertrag: "vers",
|
||||
vertragsdetails: "vers",
|
||||
schutz: "vertragsdetails",
|
||||
steuer: "vers",
|
||||
@@ -62,31 +68,43 @@ export const ZURUECK: Partial<Record<SeitenName, SeitenName>> = {
|
||||
sbuch: "service",
|
||||
einst: "home",
|
||||
sicherheit: "home",
|
||||
tuerenklappen: "sicherheit",
|
||||
standort: "home",
|
||||
live: "home",
|
||||
}
|
||||
|
||||
/** Überschrift je Seite. Leer bedeutet: die Seite bringt ihre eigene mit. */
|
||||
/**
|
||||
* Überschrift je Seite — wortgleich mit dem Kopf, den `render()` im Panel
|
||||
* setzt (`head[1]`, audi-dashboard-app.js). Neun Titel wichen davon ab
|
||||
* (Fahrzeugdaten/Batterie/Versicherung & Steuer/Beitrag/Notruf/Werkstatt/
|
||||
* Wartungsplan/Fahrt/Tankvorgang); das waren keine Entscheidungen, sondern
|
||||
* Drift. `live` hat keine Panel-Entsprechung (companion-app-eigener Screen).
|
||||
*/
|
||||
export const TITEL: Record<SeitenName, string> = {
|
||||
home: "Übersicht",
|
||||
audi: "Mein Audi",
|
||||
trips: "Fahrten",
|
||||
stat: "Statistik",
|
||||
fuel: "Tanken",
|
||||
ident: "Fahrzeugdaten",
|
||||
battverlauf: "Batterie",
|
||||
ident: "Identität und Technik",
|
||||
battverlauf: "Batteriespannung",
|
||||
battliste: "Messwerte",
|
||||
service: "Service",
|
||||
werkstatt: "Werkstatt",
|
||||
sbuch: "Servicebuch",
|
||||
reifen: "Reifen",
|
||||
vers: "Versicherung & Steuer",
|
||||
beitrag: "Beitrag",
|
||||
werkstatt: "Autohaus",
|
||||
sbuch: "Eintrag",
|
||||
reifen: "Räder",
|
||||
vers: "Versicherung/Steuer",
|
||||
beitrag: "Beitrag anpassen",
|
||||
vertrag: "Vertrag",
|
||||
vertragsdetails: "Vertragsdetails",
|
||||
schutz: "Schutzbrief",
|
||||
notruf: "Notruf",
|
||||
notruf: "Rufnummern",
|
||||
steuer: "Kfz-Steuer",
|
||||
trip: "Fahrt",
|
||||
fill: "Tankvorgang",
|
||||
trip: "Einzelfahrt",
|
||||
fill: "Einzelbeleg",
|
||||
sicherheit: "Fahrzeugstatus",
|
||||
tuerenklappen: "Türen und Klappen",
|
||||
standort: "Standort",
|
||||
live: "Fahrt läuft",
|
||||
einst: "Einstellungen",
|
||||
}
|
||||
|
||||
@@ -7,17 +7,19 @@
|
||||
*
|
||||
* 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
|
||||
* anhebt); Werte über 13,0 V gelten als „während der Fahrt gemessen" und
|
||||
* taugen nicht als Ruhewert.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
import { Tile } from "@audi-dash/ui"
|
||||
import { IconButton, Tile } from "@audi-dash/ui"
|
||||
|
||||
import { ENTITAETEN } from "../api"
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { datum, de } from "../format"
|
||||
import type { SeitenName } from "../navigation"
|
||||
import { SymbolBatterie, SymbolListe } from "../symbole"
|
||||
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
|
||||
|
||||
interface Tageswert {
|
||||
@@ -26,30 +28,141 @@ interface Tageswert {
|
||||
max: number | null
|
||||
min_ts?: string
|
||||
max_ts?: string
|
||||
min_temp_c?: number | null
|
||||
}
|
||||
|
||||
/** Ab hier gilt eine Messung als nicht mehr in Ruhe aufgenommen. */
|
||||
const AGM_RUHE_MAX_V = 13.2
|
||||
/** Ab hier gilt eine Messung nicht mehr als Batterie(ruhe)spannung, sondern
|
||||
schon als Generatorspannung - vom Nutzer direkt festgelegt ("max voltage
|
||||
for AGM Battery is 13V, everything above is the generator"), identisch
|
||||
mit AGM_RUHE_MAX_V im Panel (audi-dashboard-app.js). Getrennt von
|
||||
LADEKURVE unten: eine Messung zwischen 12,8 und 13,0 V zählt hier noch als
|
||||
gültige Batteriemessung (Diagrammpunkt, fließt in den Trend ein), zeigt
|
||||
aber weiterhin 100 % Ladezustand ("12,8v voll was already right" - der
|
||||
Nutzer bestätigte den ursprünglichen 12,8-V-Eckwert ausdrücklich). Auch
|
||||
von BatterieListe.tsx verwendet, für die "Generatorspannung"-
|
||||
Kennzeichnung dort - eine einzige Schwelle für beide Ansichten. */
|
||||
export const AGM_RUHE_MAX_V = 13.0
|
||||
|
||||
/** Spannung → Ladezustand für eine AGM-Batterie in Ruhe. */
|
||||
/** Spannung → Ladezustand für eine AGM-Batterie in Ruhe, linear zwischen den
|
||||
Stützstellen interpoliert - identische Tabelle und Rechenweg wie im Panel
|
||||
(`bvSocProzent()`/`BV_SOC_TABELLE`, `audi-dashboard-app.js`), auf
|
||||
Nutzerwunsch angeglichen: die vorherige Stufenkurve hier lieferte bei
|
||||
derselben Spannung teils deutlich andere Prozentwerte als das Panel (z. B.
|
||||
12,1 V: 40 % im Panel, 25 % hier) - beide Ansichten sollen bei derselben
|
||||
Messung dasselbe zeigen. Letzte Stützstelle bleibt bei 12,8 V/100 % -
|
||||
unabhängig von der oben auf 13,0 V angehobenen AGM_RUHE_MAX_V, siehe
|
||||
deren eigenen Kommentar. */
|
||||
const LADEKURVE: [number, number][] = [
|
||||
[12.8, 100],
|
||||
[12.6, 90],
|
||||
[12.4, 75],
|
||||
[12.2, 50],
|
||||
[12.0, 25],
|
||||
[11.8, 10],
|
||||
[10.5, 0], [11.8, 10], [11.9, 20], [12.0, 30], [12.1, 40],
|
||||
[12.2, 50], [12.4, 60], [12.5, 70], [12.6, 80], [12.7, 90], [12.8, 100],
|
||||
]
|
||||
|
||||
/** Deckungsgleich mit bvHexZuRgb()/bvMischen()/bvFarbe()/bvFarbstufen() im
|
||||
Panel: der einzelne Messpunkt ist nach Ruhespannung eingefärbt (rot →
|
||||
gelb → grün), nicht - wie hier bis 2026-08-30 - durchgehend `var(--red)`.
|
||||
Rot als durchgehende Diagrammfarbe verstößt gegen die eigene Projektregel
|
||||
"Rot ist Akzent, nie Fläche" (siehe die Statistik-/TabBar-Funde weiter
|
||||
oben in dieser Datei) und war hier schlicht nie an die Panel-Logik
|
||||
angeglichen worden. */
|
||||
function hexZuRgb(hex: string): [number, number, number] {
|
||||
const h = hex.trim().replace("#", "")
|
||||
const r = parseInt(h.slice(0, 2), 16)
|
||||
const g = parseInt(h.slice(2, 4), 16)
|
||||
const b = parseInt(h.slice(4, 6), 16)
|
||||
return [Number.isFinite(r) ? r : 0, Number.isFinite(g) ? g : 0, Number.isFinite(b) ? b : 0]
|
||||
}
|
||||
function mischen(a: [number, number, number], b: [number, number, number], t: number): [number, number, number] {
|
||||
return [
|
||||
Math.round(a[0] + (b[0] - a[0]) * t),
|
||||
Math.round(a[1] + (b[1] - a[1]) * t),
|
||||
Math.round(a[2] + (b[2] - a[2]) * t),
|
||||
]
|
||||
}
|
||||
function farbstufenLesen() {
|
||||
const stil = typeof getComputedStyle === "function" ? getComputedStyle(document.documentElement) : null
|
||||
const wert = (name: string, ersatz: string) => {
|
||||
const v = stil?.getPropertyValue(name).trim()
|
||||
return v ? v : ersatz
|
||||
}
|
||||
return {
|
||||
bad: hexZuRgb(wert("--bad", "#FF453A")),
|
||||
warn: hexZuRgb(wert("--warn", "#FFD60A")),
|
||||
ok: hexZuRgb(wert("--ok", "#30D158")),
|
||||
}
|
||||
}
|
||||
function farbeFuerSpannung(v: number, stufen: ReturnType<typeof farbstufenLesen>): string {
|
||||
let rgb: [number, number, number]
|
||||
if (v <= 11.6) rgb = stufen.bad
|
||||
else if (v <= 12.2) rgb = mischen(stufen.bad, stufen.warn, (v - 11.6) / (12.2 - 11.6))
|
||||
else if (v <= 12.8) rgb = mischen(stufen.warn, stufen.ok, (v - 12.2) / (12.8 - 12.2))
|
||||
else rgb = stufen.ok
|
||||
return `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`
|
||||
}
|
||||
|
||||
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
|
||||
const t = LADEKURVE
|
||||
if (spannung <= t[0]![0]) return 0
|
||||
if (spannung >= t[t.length - 1]![0]) return 100
|
||||
for (let i = 0; i < t.length - 1; i++) {
|
||||
const [v0, s0] = t[i]!
|
||||
const [v1, s1] = t[i + 1]!
|
||||
if (spannung >= v0 && spannung <= v1) return Math.round(s0 + ((s1 - s0) * (spannung - v0)) / (v1 - v0))
|
||||
}
|
||||
return 100
|
||||
}
|
||||
|
||||
export function Batterie() {
|
||||
const { api, fahrzeug } = useDaten()
|
||||
/** `bvSocLabel()` im Panel. */
|
||||
function socLabel(soc: number): string {
|
||||
return soc >= 90 ? "Sehr gut" : soc >= 70 ? "Gut" : soc >= 50 ? "Mittel" : soc >= 30 ? "Schwach" : "Kritisch"
|
||||
}
|
||||
|
||||
/**
|
||||
* `bvSohBewertung()` im Panel: Trend über die früheren gegen die späteren
|
||||
* Messwerte, mit denselben Schwellen und demselben Wortlaut. Fehlte hier — die
|
||||
* Bewertungskachel gab es gar nicht, stattdessen stand der Trend als nackte
|
||||
* Wertzeile ohne Einordnung.
|
||||
*/
|
||||
const TAG_MS = 86_400_000
|
||||
function sohBewertung(
|
||||
punkte: { t: number; v: number }[],
|
||||
): { status: "ok" | "warn" | "bad" | null; text: string; delta: number } {
|
||||
if (punkte.length < 6)
|
||||
return { status: null, text: "Noch nicht genug Messwerte für eine Trendbewertung.", delta: 0 }
|
||||
const spanneTage = (punkte[punkte.length - 1]!.t - punkte[0]!.t) / TAG_MS
|
||||
if (spanneTage < 60)
|
||||
return {
|
||||
status: null,
|
||||
text: "Noch zu kurzer Zeitraum für eine Trendbewertung (mind. ~2 Monate nötig).",
|
||||
delta: 0,
|
||||
}
|
||||
const fensterMs = Math.min(90, spanneTage / 3) * TAG_MS
|
||||
const alt = punkte.filter((p) => p.t <= punkte[0]!.t + fensterMs)
|
||||
const neu = punkte.filter((p) => p.t >= punkte[punkte.length - 1]!.t - fensterMs)
|
||||
const mittel = (l: { v: number }[]) => l.reduce((s, p) => s + p.v, 0) / l.length
|
||||
const delta = mittel(neu) - mittel(alt)
|
||||
const status = delta >= -0.05 ? "ok" : delta >= -0.15 ? "warn" : "bad"
|
||||
const text =
|
||||
delta >= -0.05
|
||||
? "Stabil - kein auffälliger Rückgang der Ruhespannung."
|
||||
: delta >= -0.15
|
||||
? "Leicht sinkend - im für alternde AGM-Batterien üblichen Rahmen."
|
||||
: "Deutlicher Rückgang - kann auf eine alternde oder geschwächte Batterie hindeuten."
|
||||
return { status, text, delta }
|
||||
}
|
||||
|
||||
function statusFarbe(status: "ok" | "warn" | "bad" | null): string {
|
||||
return status === "ok"
|
||||
? "var(--ok)"
|
||||
: status === "warn"
|
||||
? "var(--warn)"
|
||||
: status === "bad"
|
||||
? "var(--bad)"
|
||||
: "var(--fg3)"
|
||||
}
|
||||
|
||||
export function Batterie({ geheZu }: { geheZu: (name: SeitenName) => void }) {
|
||||
const { api } = useDaten()
|
||||
const [verlauf, setzeVerlauf] = useState<Tageswert[] | null>(null)
|
||||
const [fehler, setzeFehler] = useState(false)
|
||||
|
||||
@@ -71,7 +184,12 @@ export function Batterie() {
|
||||
|
||||
if (!verlauf) return <div className="dm-laden">Verlauf wird geladen …</div>
|
||||
|
||||
const messwerte = verlauf.filter((t) => t.min != null)
|
||||
// Wie bvPunkte im Panel: Werte über AGM_RUHE_MAX_V sind Generatorspannung,
|
||||
// keine Ruhespannung - ausgeschlossen von Kopfzeile, Trend UND Diagramm
|
||||
// (eine einzige gefilterte Liste, kein separater "alle Messungen"-Bestand).
|
||||
// Ohne diesen Filter hätte ein Tag, an dem das Fahrzeug nie lange genug
|
||||
// stand, seine Generatorspannung fälschlich als "Ruhespannung" gezeigt.
|
||||
const messwerte = verlauf.filter((t) => t.min != null && t.min <= AGM_RUHE_MAX_V)
|
||||
|
||||
if (messwerte.length === 0) {
|
||||
return (
|
||||
@@ -84,94 +202,333 @@ export function Batterie() {
|
||||
|
||||
const letzter = messwerte[messwerte.length - 1]!
|
||||
const soc = letzter.min != null ? ladezustand(letzter.min) : null
|
||||
const stufen = farbstufenLesen()
|
||||
const punkte = messwerte
|
||||
.filter((t) => t.min != null && t.min_ts)
|
||||
.map((t) => ({ t: new Date(t.min_ts!).getTime(), v: t.min! }))
|
||||
const soh = sohBewertung(punkte)
|
||||
|
||||
// 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
|
||||
|
||||
/* Aufbau wie vBatterieverlauf(): eine Kachel „Verlauf" mit Diagramm und
|
||||
Ruhespannungs-Skala, darunter eine Kachel „Bewertung" mit Ladezustand und
|
||||
Gesundheitszustand. Hier stand bis 2026-08-30 eine einzelne Kachel
|
||||
„Ruhespannung" mit großer Zahl und vier Wertzeilen — die Farbskala, die
|
||||
Bewertungskachel und der Bewertungstext fehlten ganz, dafür gab es eine
|
||||
Zeile „Aktuell gemeldet", die das Panel hier nicht führt. */
|
||||
return (
|
||||
<>
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Ruhespannung</span>
|
||||
<div className="dm-detail__kopf">
|
||||
<div className="dm-detail__zahl">
|
||||
{letzter.min != null ? de(letzter.min, 2) : "—"}
|
||||
<span className="dm-detail__einheit">V</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span className="ads-eyebrow">Verlauf</span>
|
||||
<IconButton aria-label="Messwertliste" onClick={() => geheZu("battliste")}>
|
||||
<SymbolListe groesse={18} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<Verlaufsdiagramm werte={messwerte} />
|
||||
<div className="dm-skalakopf">
|
||||
<SymbolBatterie groesse={15} />
|
||||
<span>Ruhespannung</span>
|
||||
</div>
|
||||
<div className="dm-skalabalken" />
|
||||
<div className="dm-skalamarken">
|
||||
<small>11,6 V · leer</small>
|
||||
<small>12,2 V · 50 %</small>
|
||||
<small>12,8 V · voll</small>
|
||||
</div>
|
||||
</Tile>
|
||||
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Bewertung</span>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile label="Letzte Messung" wert={datum(letzter.datum)} />
|
||||
<Wertzeile
|
||||
label="Geschätzter Ladezustand"
|
||||
wert={soc != null ? `${de(soc)} %` : "—"}
|
||||
zusatz={soc == null ? "Messung nicht in Ruhe aufgenommen" : undefined}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Trend über den Zeitraum"
|
||||
wert={trend != null ? `${trend >= 0 ? "+" : ""}${de(trend, 2)} V` : "—"}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Aktuell gemeldet"
|
||||
label="Ladezustand (SOC)"
|
||||
wert={
|
||||
fahrzeug?.batteriespannung != null
|
||||
? `${de(fahrzeug.batteriespannung, 2)} V`
|
||||
: "—"
|
||||
letzter.min != null && soc != null ? (
|
||||
<span className="dm-bewertung">
|
||||
<span
|
||||
className="ads-dot"
|
||||
style={{ background: farbeFuerSpannung(letzter.min, stufen) }}
|
||||
/>
|
||||
{de(letzter.min, 1)} V · {de(soc)} % · {socLabel(soc)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="dm-offen">unbekannt</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Gesundheitszustand (SOH-Trend)"
|
||||
wert={
|
||||
soh.status ? (
|
||||
<span className="dm-bewertung">
|
||||
<span className="ads-dot" style={{ background: statusFarbe(soh.status) }} />
|
||||
{soh.delta >= 0 ? "+" : ""}
|
||||
{de(soh.delta, 2)} V
|
||||
</span>
|
||||
) : (
|
||||
<span className="dm-offen">-</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<p className="dm-fussnote">
|
||||
Der Ladezustand wird aus dem Tagesminimum geschätzt — das ist die Spannung kurz nach dem
|
||||
Start, bevor die Lichtmaschine sie anhebt. Der Trend vergleicht die frühere mit der
|
||||
späteren Hälfte des Zeitraums; er ist ein Hinweis, keine Kapazitätsmessung.
|
||||
</p>
|
||||
<span className="dm-bewertungstext">{soh.text}</span>
|
||||
</Tile>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Von Hand gezeichnete Verlaufskurve aus Tagesminimum und -maximum. */
|
||||
function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
|
||||
const breite = 320
|
||||
const hoehe = 120
|
||||
const rand = 4
|
||||
/** Von Hand gezeichnete Verlaufskurve aus dem Tagesminimum, mit einem Punkt je
|
||||
Messung. Der Tageshöchstwert bekommt bewusst KEINE eigene Linie mehr
|
||||
(Nutzerwunsch, Parität zum Panel) - er erscheint als Text, sobald ein Punkt
|
||||
angetippt wird, zusammen mit Datum und Uhrzeit.
|
||||
|
||||
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)
|
||||
Die viewBox folgt der tatsächlich gemessenen Pixelbreite, statt eine feste
|
||||
320er-Fläche mit preserveAspectRatio="none" breitzuziehen: sonst würden die
|
||||
neuen Punkte auf breiten Anzeigen zu Ellipsen verzerrt (im Panel war
|
||||
dasselbe Problem an den auseinandergezogenen Achsenzahlen sichtbar). */
|
||||
/** Hält einen Zoomausschnitt innerhalb des vorhandenen Zeitraums. */
|
||||
function begrenzen(
|
||||
bereich: [number, number],
|
||||
voll: [number, number],
|
||||
): [number, number] {
|
||||
const spanne = Math.min(bereich[1] - bereich[0], voll[1] - voll[0])
|
||||
let von = bereich[0]
|
||||
if (von < voll[0]) von = voll[0]
|
||||
if (von + spanne > voll[1]) von = voll[1] - spanne
|
||||
return [von, von + spanne]
|
||||
}
|
||||
|
||||
const BV_H = 170
|
||||
const BV_ML = 40
|
||||
const BV_MR = 10
|
||||
const BV_MT = 10
|
||||
const BV_MB = 12
|
||||
|
||||
function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
|
||||
const hoehe = BV_H
|
||||
const svgRef = useRef<SVGSVGElement | null>(null)
|
||||
const [breite, setzeBreite] = useState(320)
|
||||
const [gewaehlt, setzeGewaehlt] = useState<number | null>(null)
|
||||
/* Zoomen und Verschieben wie im Panel (bvDomain/bvZeiger/bvGestStart):
|
||||
zwei Finger spreizen den Zeitausschnitt, einer verschiebt ihn, der Knopf
|
||||
stellt ihn zurück. Fehlte hier ganz — die Kurve war unveränderlich. */
|
||||
const [ausschnitt, setzeAusschnitt] = useState<[number, number] | null>(null)
|
||||
const zeiger = useRef(new Map<number, number>())
|
||||
const gestStart = useRef<{ domain: [number, number]; mitte: number; spanne: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = svgRef.current
|
||||
if (!el || typeof ResizeObserver === "undefined") return
|
||||
const messen = () => {
|
||||
const w = el.getBoundingClientRect().width
|
||||
if (w > 0) setzeBreite(w)
|
||||
}
|
||||
messen()
|
||||
const beobachter = new ResizeObserver(messen)
|
||||
beobachter.observe(el)
|
||||
return () => beobachter.disconnect()
|
||||
}, [])
|
||||
|
||||
// Feste Achse 10-15V (Nutzerwunsch): deckt den relevanten Bereich ab
|
||||
// (12V-Bleibatterie, Ladespannung des Alternators mit Reserve).
|
||||
const kleinster = 10
|
||||
const groesster = 15
|
||||
const spanne = groesster - kleinster
|
||||
|
||||
// Zeitachse: echte Zeitstempel statt gleichmäßiger Indexschritte, sonst
|
||||
// ließe sich der Ausschnitt nicht sinnvoll zoomen.
|
||||
const zeitVon = (t: Tageswert) => new Date(t.min_ts ?? t.datum).getTime()
|
||||
const vollDomain: [number, number] =
|
||||
werte.length >= 2
|
||||
? [zeitVon(werte[0]!), zeitVon(werte[werte.length - 1]!)]
|
||||
: [0, 1]
|
||||
const domain = ausschnitt ?? vollDomain
|
||||
const zeitSpanne = Math.max(1, domain[1] - domain[0])
|
||||
|
||||
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)
|
||||
BV_ML +
|
||||
((zeitVon(werte[i]!) - domain[0]) / zeitSpanne) * (breite - BV_ML - BV_MR)
|
||||
// Anteil auf [0,1] geklemmt statt roh extrapoliert - ein Ausreißer außerhalb
|
||||
// von kleinster/groesster zeichnet sich sonst weit außerhalb der Fläche.
|
||||
const y = (v: number) => {
|
||||
const anteil = Math.max(0, Math.min(1, (v - kleinster) / spanne))
|
||||
return hoehe - BV_MB - anteil * (hoehe - BV_MT - BV_MB)
|
||||
}
|
||||
|
||||
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(" ")
|
||||
const punkteText = werte
|
||||
.map((t, i) => (t.min == null ? null : `${x(i).toFixed(1)},${y(t.min).toFixed(1)}`))
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
|
||||
const punktWaehlen = (ereignis: { clientX: number }) => {
|
||||
const el = svgRef.current
|
||||
if (!el || werte.length === 0) return
|
||||
const kasten = el.getBoundingClientRect()
|
||||
const anteil = (ereignis.clientX - kasten.left - BV_ML) / Math.max(1, breite - BV_ML - BV_MR)
|
||||
const zeit = domain[0] + anteil * zeitSpanne
|
||||
let bester = 0
|
||||
let abstand = Infinity
|
||||
werte.forEach((t, i) => {
|
||||
const a = Math.abs(zeitVon(t) - zeit)
|
||||
if (a < abstand) {
|
||||
abstand = a
|
||||
bester = i
|
||||
}
|
||||
})
|
||||
setzeGewaehlt((vorher) => (vorher === bester ? null : bester))
|
||||
}
|
||||
|
||||
const zeigerRunter = (e: React.PointerEvent<SVGSVGElement>) => {
|
||||
zeiger.current.set(e.pointerId, e.clientX)
|
||||
if (zeiger.current.size === 2) {
|
||||
const [a, b] = [...zeiger.current.values()]
|
||||
gestStart.current = {
|
||||
domain: [domain[0], domain[1]],
|
||||
mitte: (a! + b!) / 2,
|
||||
spanne: Math.abs(a! - b!) || 1,
|
||||
}
|
||||
} else if (zeiger.current.size === 1) {
|
||||
gestStart.current = { domain: [domain[0], domain[1]], mitte: e.clientX, spanne: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
const zeigerBewegt = (e: React.PointerEvent<SVGSVGElement>) => {
|
||||
if (!zeiger.current.has(e.pointerId) || !gestStart.current) return
|
||||
zeiger.current.set(e.pointerId, e.clientX)
|
||||
const nutzbar = Math.max(1, breite - BV_ML - BV_MR)
|
||||
const start = gestStart.current
|
||||
const startSpanne = start.domain[1] - start.domain[0]
|
||||
if (zeiger.current.size >= 2) {
|
||||
const [a, b] = [...zeiger.current.values()]
|
||||
const faktor = start.spanne / Math.max(1, Math.abs(a! - b!))
|
||||
const neueSpanne = Math.max(
|
||||
3600_000,
|
||||
Math.min(vollDomain[1] - vollDomain[0], startSpanne * faktor),
|
||||
)
|
||||
const mitteZeit = start.domain[0] + (startSpanne * (start.mitte - BV_ML)) / nutzbar
|
||||
setzeAusschnitt(begrenzen([mitteZeit - neueSpanne / 2, mitteZeit + neueSpanne / 2], vollDomain))
|
||||
} else {
|
||||
const verschub = ((start.mitte - e.clientX) / nutzbar) * startSpanne
|
||||
setzeAusschnitt(
|
||||
begrenzen([start.domain[0] + verschub, start.domain[1] + verschub], vollDomain),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const zeigerHoch = (e: React.PointerEvent<SVGSVGElement>) => {
|
||||
zeiger.current.delete(e.pointerId)
|
||||
if (zeiger.current.size === 0) gestStart.current = null
|
||||
}
|
||||
|
||||
const aktiv = gewaehlt != null ? werte[gewaehlt] : undefined
|
||||
const zeitpunkt = aktiv?.min_ts ? new Date(aktiv.min_ts) : null
|
||||
const stufen = farbstufenLesen()
|
||||
const kurz = (wert?: string) =>
|
||||
wert
|
||||
? new Date(wert).toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "2-digit",
|
||||
})
|
||||
: ""
|
||||
|
||||
/* Gitterlinien mit Achsenbeschriftung und die weiche Flächenfüllung unter der
|
||||
Linie fehlten hier ganz — das Panel zeichnet beide (bvZeichnen()), samt
|
||||
dem Zeitraum unter der Fläche. Ohne sie stand die Kurve ohne jeden
|
||||
Maßstab im Raum. */
|
||||
const flaeche =
|
||||
punkteText.length > 0 && werte.length >= 2
|
||||
? `M${x(0).toFixed(1)},${(hoehe - BV_MB).toFixed(1)} L${punkteText.split(" ").join(" L")} L${x(
|
||||
werte.length - 1,
|
||||
).toFixed(1)},${(hoehe - BV_MB).toFixed(1)} Z`
|
||||
: ""
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="dm-diagramm"
|
||||
viewBox={`0 0 ${breite} ${hoehe}`}
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label={`Spannungsverlauf über ${werte.length} Tage`}
|
||||
>
|
||||
<path d={linie((t) => t.max)} fill="none" stroke="var(--fg3)" strokeWidth="1" />
|
||||
<path d={linie((t) => t.min)} fill="none" stroke="var(--red)" strokeWidth="1.5" />
|
||||
</svg>
|
||||
<>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className="dm-diagramm"
|
||||
viewBox={`0 0 ${breite.toFixed(1)} ${hoehe}`}
|
||||
role="img"
|
||||
aria-label={`Spannungsverlauf über ${werte.length} Messungen`}
|
||||
onClick={punktWaehlen}
|
||||
onPointerDown={zeigerRunter}
|
||||
onPointerMove={zeigerBewegt}
|
||||
onPointerUp={zeigerHoch}
|
||||
onPointerCancel={zeigerHoch}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="dmBvGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor="var(--fg)" stopOpacity="0.16" />
|
||||
<stop offset="1" stopColor="var(--fg)" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{[0, 1, 2, 3].map((i) => {
|
||||
const v = kleinster + (spanne * i) / 3
|
||||
const yy = y(v)
|
||||
return (
|
||||
<g key={i}>
|
||||
<line
|
||||
x1={BV_ML}
|
||||
y1={yy}
|
||||
x2={breite - BV_MR}
|
||||
y2={yy}
|
||||
stroke="var(--line)"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<text x={BV_ML - 6} y={yy + 3} fontSize="11" fill="var(--fg3)" textAnchor="end">
|
||||
{de(v, 1)}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
{flaeche && <path d={flaeche} fill="url(#dmBvGrad)" stroke="none" />}
|
||||
<polyline
|
||||
points={punkteText}
|
||||
fill="none"
|
||||
stroke="var(--fg)"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{werte.map((t, i) =>
|
||||
t.min == null ? null : (
|
||||
<circle
|
||||
key={t.datum}
|
||||
cx={x(i)}
|
||||
cy={y(t.min)}
|
||||
r={gewaehlt === i ? 4.5 : 2.4}
|
||||
fill={farbeFuerSpannung(t.min, stufen)}
|
||||
{...(gewaehlt === i ? { stroke: "var(--fg)", strokeWidth: 1 } : {})}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</svg>
|
||||
<div className="dm-diagramm__fuss">
|
||||
<span className="ads-eyebrow">
|
||||
{kurz(new Date(domain[0]).toISOString())} – {kurz(new Date(domain[1]).toISOString())}
|
||||
</span>
|
||||
<button type="button" className="ads-action dm-zoomzurueck" onClick={() => setzeAusschnitt(null)}>
|
||||
Zoom zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
{aktiv && (
|
||||
<div className="dm-diagramm__tooltip">
|
||||
{[
|
||||
zeitpunkt
|
||||
? `${datum(aktiv.datum)} · ${zeitpunkt.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr`
|
||||
: datum(aktiv.datum),
|
||||
aktiv.min != null ? `Min ${de(aktiv.min, 2)} V` : null,
|
||||
aktiv.max != null ? `Max ${de(aktiv.max, 2)} V` : null,
|
||||
aktiv.min_temp_c != null ? `${de(aktiv.min_temp_c, 1)} °C` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Batteriespannungs-Messwertliste — ein Eintrag pro Tag (dieselben Daten wie
|
||||
* das Diagramm in Batterie.tsx), aber als Liste: Datum, Uhrzeit, Spannung,
|
||||
* Außentemperatur. Erreichbar über das Listen-Symbol oben rechts im
|
||||
* Diagramm.
|
||||
*
|
||||
* Jede Zeile lässt sich wie Fahrten/Tankvorgänge nach links wegwischen, um
|
||||
* sie zu löschen (Zeilenmenue - Wischgeste plus sichtbares Menü, siehe dort).
|
||||
*
|
||||
* Liegt der Tagesminimalwert über AGM_RUHE_MAX_V (Batterie.tsx), wurde er nie
|
||||
* in Ruhe gemessen (das Fahrzeug stand an diesem Tag nie lange genug still) -
|
||||
* die Zeile zeigt dann "Generatorspannung" statt so zu tun, als sei es ein
|
||||
* Ruhewert.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { Pill, Tile } from "@audi-dash/ui"
|
||||
|
||||
import { ENTITAETEN } from "../api"
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { datum, datumUndZeit, de } from "../format"
|
||||
import { Leerzustand } from "./bausteine"
|
||||
import { AGM_RUHE_MAX_V } from "./Batterie"
|
||||
import { LOESCH_HINWEIS } from "./bestaetigung"
|
||||
import { Zeilenmenue } from "./Zeilenmenue"
|
||||
|
||||
interface Tageswert {
|
||||
datum: string
|
||||
min: number | null
|
||||
max: number | null
|
||||
min_ts?: string
|
||||
min_temp_c?: number | null
|
||||
}
|
||||
|
||||
export function BatterieListe() {
|
||||
const { api } = useDaten()
|
||||
const [verlauf, setzeVerlauf] = useState<Tageswert[] | null>(null)
|
||||
const [fehler, setzeFehler] = useState(false)
|
||||
|
||||
const neuLaden = () => {
|
||||
void api.rest
|
||||
.datenLesen<Tageswert[]>(ENTITAETEN.batterieverlauf)
|
||||
.then((daten) => setzeVerlauf(Array.isArray(daten) ? daten : []))
|
||||
.catch(() => setzeFehler(true))
|
||||
}
|
||||
|
||||
useEffect(neuLaden, [api])
|
||||
|
||||
if (fehler) {
|
||||
return (
|
||||
<Leerzustand
|
||||
titel="Liste nicht abrufbar"
|
||||
text="Der Server hat die Messwertliste nicht geliefert. Beim nächsten Aufruf klappt es vielleicht wieder."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!verlauf) return <div className="dm-laden">Liste wird geladen …</div>
|
||||
|
||||
const messwerte = verlauf.filter((t) => t.min != null).slice().reverse()
|
||||
|
||||
if (messwerte.length === 0) {
|
||||
return (
|
||||
<Leerzustand
|
||||
titel="Keine Messwerte"
|
||||
text="Für dieses Fahrzeug liefert die Anbindung derzeit keinen Spannungssensor."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const loeschen = async (eintrag: Tageswert) => {
|
||||
await api.batterieverlaufEintragLoeschen(eintrag.datum)
|
||||
neuLaden()
|
||||
}
|
||||
|
||||
return (
|
||||
<Tile>
|
||||
{messwerte.map((t) => (
|
||||
<Zeilenmenue
|
||||
key={t.datum}
|
||||
loeschFrage={`Messwert vom ${datum(t.datum)} löschen?`}
|
||||
loeschHinweis={LOESCH_HINWEIS.batt}
|
||||
beiLoeschen={() => void loeschen(t)}
|
||||
kinder={
|
||||
<div className="dm-listenzeile">
|
||||
<span className="dm-listenzeile__haupt">
|
||||
<span className="dm-listenzeile__titel">
|
||||
{de(t.min!, 2)} V
|
||||
<span className="dm-listenzeile__neben">
|
||||
{t.min_temp_c != null ? `${de(t.min_temp_c, 1)} °C` : "–"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="dm-listenzeile__unter">
|
||||
{t.min_ts ? datumUndZeit(t.min_ts) : datum(t.datum)}
|
||||
</span>
|
||||
</span>
|
||||
{t.min! > AGM_RUHE_MAX_V && <Pill>Generatorspannung</Pill>}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Tile>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* „Beleg hochladen" — Vorlage: `vBelegPopup()` im Panel.
|
||||
*
|
||||
* Bis 2026-08-31 öffnete der Beleg-Knopf in der App direkt die Dateiauswahl.
|
||||
* Auf dem iPhone landete man damit im Dateispeicher, und ein aus der Mail
|
||||
* kopiertes PDF war überhaupt nicht einzufügen — im Panel gab es diesen Weg
|
||||
* längst. Geteilt zwischen dem Anlegen (Tanken.tsx) und dem Nachtragen
|
||||
* (TankDetail.tsx), damit beide nicht auseinanderlaufen.
|
||||
*
|
||||
* Ein Auswahlblatt, kein Popup: es ist eine kurze Liste von Wegen zum selben
|
||||
* Ziel, und genau dafür sieht Apple das Aktionsblatt vor. „Abbrechen" steht
|
||||
* abgesetzt in eigener Karte — das macht es zum Ausweg statt zur vierten
|
||||
* Wahlmöglichkeit.
|
||||
*
|
||||
* Welche Wege angeboten werden, entscheidet das Gerät, nicht der Geschmack:
|
||||
* am Telefon ist Einfügen der Hauptweg, am Rechner Ziehen bzw. Dateiauswahl.
|
||||
* Begründung in daten/zwischenablage.ts.
|
||||
*/
|
||||
|
||||
import { useRef, useState } from "react"
|
||||
|
||||
import { ActionSheet } from "@audi-dash/ui"
|
||||
|
||||
import {
|
||||
ZwischenablageFehler,
|
||||
pdfAusDateiliste,
|
||||
pdfAusZwischenablage,
|
||||
zwischenablageKnopfSinnvoll,
|
||||
} from "../daten/zwischenablage"
|
||||
|
||||
export function BelegPopup({
|
||||
offen,
|
||||
beiAbbruch,
|
||||
beiDatei,
|
||||
}: {
|
||||
offen: boolean
|
||||
beiAbbruch: () => void
|
||||
/** Bekommt das PDF. Das Blatt schließt sich vorher selbst. */
|
||||
beiDatei: (datei: File) => void
|
||||
}) {
|
||||
const dateiwahl = useRef<HTMLInputElement | null>(null)
|
||||
const [fehler, setzeFehler] = useState<string | null>(null)
|
||||
const [darueber, setzeDarueber] = useState(false)
|
||||
// Einmal beim Aufbau bestimmt: ein Gerätewechsel mitten im Dialog gibt es
|
||||
// nicht, und ein bei jedem Rendern neu befragtes matchMedia wäre nur Aufwand.
|
||||
const [telefon] = useState(zwischenablageKnopfSinnvoll)
|
||||
|
||||
const uebernehmen = (datei: File) => {
|
||||
setzeFehler(null)
|
||||
beiDatei(datei)
|
||||
}
|
||||
|
||||
const einfuegen = async () => {
|
||||
try {
|
||||
uebernehmen(await pdfAusZwischenablage())
|
||||
} catch (f) {
|
||||
setzeFehler(
|
||||
f instanceof ZwischenablageFehler
|
||||
? f.message
|
||||
: 'Die Zwischenablage ließ sich nicht lesen - bitte "Datei auswählen".',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const aktionen = [
|
||||
...(telefon
|
||||
? [{ label: "Aus Zwischenablage einfügen", onClick: () => void einfuegen() }]
|
||||
: []),
|
||||
{ label: "Datei auswählen", onClick: () => dateiwahl.current?.click() },
|
||||
]
|
||||
|
||||
// Nur wenn es wirklich etwas zu zeigen gibt - sonst entstuende im Blatt
|
||||
// eine leere Zeile samt Trennlinie.
|
||||
const zusatz =
|
||||
!telefon || fehler ? (
|
||||
<>
|
||||
{!telefon && (
|
||||
<div
|
||||
className={`dm-beleg-drop${darueber ? " dm-beleg-drop--darueber" : ""}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setzeDarueber(true)
|
||||
}}
|
||||
onDragLeave={() => setzeDarueber(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
setzeDarueber(false)
|
||||
const datei = pdfAusDateiliste(e.dataTransfer?.files)
|
||||
if (datei) uebernehmen(datei)
|
||||
else setzeFehler("Das war kein PDF - der Beleg muss als PDF vorliegen.")
|
||||
}}
|
||||
>
|
||||
<strong>PDF hierher ziehen</strong>
|
||||
<span>oder unten auswählen</span>
|
||||
</div>
|
||||
)}
|
||||
{fehler && (
|
||||
<p className="dm-fehler" role="alert" style={{ margin: 0 }}>
|
||||
{fehler}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Ausserhalb des Blattes: unsichtbar, muss aber im Baum stehen, damit
|
||||
der Verweis darauf zeigt. */}
|
||||
<input
|
||||
ref={dateiwahl}
|
||||
type="file"
|
||||
accept="application/pdf,.pdf"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const datei = e.target.files?.[0]
|
||||
e.target.value = ""
|
||||
if (datei) uebernehmen(datei)
|
||||
}}
|
||||
/>
|
||||
{/* Ohne Ueberschrift und ohne Erklaerzeile: wer den Knopf 'Beleg
|
||||
hochladen' gedrueckt hat, weiss bereits, was hier passiert - der
|
||||
Kopf haette es nur wiederholt (Nutzerhinweis 2026-08-31). Die
|
||||
beiden Wege benennen sich selbst. */}
|
||||
<ActionSheet open={offen} onClose={beiAbbruch} actions={aktionen}>
|
||||
{zusatz}
|
||||
</ActionSheet>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -16,20 +16,29 @@ export function Bild({
|
||||
beschreibung,
|
||||
name,
|
||||
groesse,
|
||||
dateiZeigen,
|
||||
}: {
|
||||
datei: string | null | undefined
|
||||
beschreibung: string
|
||||
name: string
|
||||
groesse?: "default" | "mini" | "round"
|
||||
/** Zeigt den Dateinamen statt der Handlungsaufforderung - so macht es das
|
||||
Panel auf der Einstellungen-Seite (dateiZeigen in bildMitPlatzhalter()). */
|
||||
dateiZeigen?: boolean
|
||||
}) {
|
||||
const url = bildUrl(datei)
|
||||
// Unter der Bezeichnung steht im Panel die Handlungsaufforderung, nicht der
|
||||
// Dateiname (`.platzhalter-aktion` zeigt "Foto hinzufügen";
|
||||
// `.platzhalter-datei` ist dort ausdrücklich ausgeblendet). In der 78px
|
||||
// breiten Miniaturansicht bleibt kein Platz dafür.
|
||||
const mitAktion = groesse !== "mini" && groesse !== "round"
|
||||
return (
|
||||
<ImagePlaceholder
|
||||
{...(url ? { src: url } : {})}
|
||||
{...(groesse ? { size: groesse } : {})}
|
||||
{...(mitAktion ? { hint: dateiZeigen && datei ? datei : "Foto hinzufügen" } : {})}
|
||||
alt={beschreibung}
|
||||
label={name}
|
||||
hint={datei ?? undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* "Datensatz sichern"/"Datensatz laden" — Einstellungen/Fahrzeugprofil.
|
||||
* Vorlage: `vDatensatzPopup()` im Panel (audi-dashboard-app.js).
|
||||
*
|
||||
* "Sichern" löst vier sofortige, rein clientseitige Exporte aus
|
||||
* (Fahrzeugprofil als JSON, Fahrten/Tankvorgänge/Wartungsplan als CSV).
|
||||
* "Laden" spiegelt dieselben vier Dateien als Dateiauswahl - Fahrzeugprofil
|
||||
* über den bestehenden profil_schreiben-Dienst (api.profilSchreiben()),
|
||||
* die drei CSV-Datensätze über den neuen csv_importieren-Dienst
|
||||
* (api.csvImportieren(), custom_components/audi_dashboard/csv_import.py):
|
||||
* eine Zeile mit bekannter ID aktualisiert nur ihre eigenen Spalten, alles
|
||||
* andere am Datensatz bleibt unangetastet - siehe dessen Moduldocstring für
|
||||
* die Begründung.
|
||||
*
|
||||
* Die CSV-Spaltennamen sind absichtlich BYTE-IDENTISCH zum Panel-Export
|
||||
* ("ID","Start","Ende","km","Art","Status" usw.) - derselbe Backend-Parser
|
||||
* bedient beide Oberflächen, eine hier exportierte Datei muss sich also auch
|
||||
* im Panel importieren lassen und umgekehrt. Vorher hatte diese Seite eigene,
|
||||
* abweichende Spaltennamen ("Beginn"/"Strecke km"/"Dauer s") - die gab es
|
||||
* nur zum Lesen in einer Tabelle, nie zum Reimport, ein Rundlauf wäre also
|
||||
* gescheitert.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
import { ActionButton, Sheet } from "@audi-dash/ui"
|
||||
|
||||
import type { CsvImportErgebnis, DataMetricApi, Fahrt, Profil, Tankvorgang } from "../api"
|
||||
import { de, eur } from "../format"
|
||||
import { csvHerunterladen } from "./csv"
|
||||
import type { ServicebuchEintrag } from "./Service"
|
||||
|
||||
type Art = "fahrzeugprofil" | "fahrten" | "tanken" | "service"
|
||||
|
||||
const ARTEN: [Art, string][] = [
|
||||
["fahrzeugprofil", "Fahrzeugprofil"],
|
||||
["fahrten", "Fahrten"],
|
||||
["tanken", "Tankvorgänge"],
|
||||
["service", "Wartungsplan"],
|
||||
]
|
||||
|
||||
function exportieren(
|
||||
art: Art,
|
||||
rohprofil: Profil | null,
|
||||
fahrten: readonly Fahrt[],
|
||||
tankvorgaenge: readonly Tankvorgang[],
|
||||
wartungsplan: readonly ServicebuchEintrag[],
|
||||
) {
|
||||
if (art === "fahrzeugprofil") {
|
||||
const blob = new Blob([JSON.stringify(rohprofil, null, 2)], { type: "application/json" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const verweis = document.createElement("a")
|
||||
verweis.href = url
|
||||
verweis.download = "fahrzeugprofil.json"
|
||||
verweis.click()
|
||||
URL.revokeObjectURL(url)
|
||||
return
|
||||
}
|
||||
if (art === "fahrten") {
|
||||
csvHerunterladen(
|
||||
"fahrten",
|
||||
["ID", "Start", "Ende", "km", "Art", "Status"],
|
||||
fahrten.map((f) => [f.trip_id, f.ts_start, f.ts_end, f.distance_km ?? "", f.art, f.status]),
|
||||
)
|
||||
return
|
||||
}
|
||||
if (art === "tanken") {
|
||||
csvHerunterladen(
|
||||
"tankvorgaenge",
|
||||
["ID", "Zeitpunkt", "Station", "Liter", "€/l", "Kosten €"],
|
||||
tankvorgaenge.map((t) => [
|
||||
t.tank_id,
|
||||
t.ts,
|
||||
t.station_name ?? "",
|
||||
t.liters ?? "",
|
||||
t.liters && t.fuel_total_eur != null ? de(t.fuel_total_eur / t.liters, 2) : "",
|
||||
t.fuel_total_eur != null ? eur(t.fuel_total_eur) : "",
|
||||
]),
|
||||
)
|
||||
return
|
||||
}
|
||||
csvHerunterladen(
|
||||
"wartungsplan",
|
||||
["Datum", "km", "Art", "Werkstatt", "Kosten €"],
|
||||
wartungsplan.map((e) => [
|
||||
e.datum ?? "",
|
||||
e.km != null ? de(e.km) : "",
|
||||
e.art ?? "",
|
||||
e.werkstatt ?? "",
|
||||
e.kosten != null ? eur(e.kosten) : "",
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
function ergebnisText(art: Art, e: CsvImportErgebnis): string {
|
||||
if (art === "fahrzeugprofil") return "Fahrzeugprofil geladen."
|
||||
const teile: string[] = []
|
||||
if (e.angelegt) teile.push(`${de(e.angelegt)} neu angelegt`)
|
||||
if (e.aktualisiert) teile.push(`${de(e.aktualisiert)} aktualisiert`)
|
||||
if (e.uebersprungen) teile.push(`${de(e.uebersprungen)} übersprungen`)
|
||||
return teile.length ? teile.join(" · ") : "Keine gültigen Zeilen in der Datei gefunden."
|
||||
}
|
||||
|
||||
export function DatensatzPopup({
|
||||
offen,
|
||||
modus,
|
||||
beiSchliessen,
|
||||
api,
|
||||
rohprofil,
|
||||
fahrten,
|
||||
tankvorgaenge,
|
||||
wartungsplan,
|
||||
beiProfilGeladen,
|
||||
beiFertig,
|
||||
}: {
|
||||
offen: boolean
|
||||
modus: "sichern" | "laden"
|
||||
beiSchliessen: () => void
|
||||
api: DataMetricApi
|
||||
rohprofil: Profil | null
|
||||
fahrten: readonly Fahrt[]
|
||||
tankvorgaenge: readonly Tankvorgang[]
|
||||
wartungsplan: readonly ServicebuchEintrag[]
|
||||
/** Nach einem geladenen Fahrzeugprofil: den neuen Stand übernehmen. */
|
||||
beiProfilGeladen: (profil: Profil) => Promise<void>
|
||||
/** Nach einem erfolgreichen CSV-Import: Fahrten-/Tankvorgangslisten neu laden. */
|
||||
beiFertig: () => void
|
||||
}) {
|
||||
const [laeuft, setzeLaeuft] = useState<Art | null>(null)
|
||||
const [ergebnis, setzeErgebnis] = useState<{ art: Art; text: string } | null>(null)
|
||||
const [fehler, setzeFehler] = useState<string | null>(null)
|
||||
const eingaben = useRef<Partial<Record<Art, HTMLInputElement | null>>>({})
|
||||
const aktiv = useRef(true)
|
||||
useEffect(() => {
|
||||
aktiv.current = true
|
||||
return () => {
|
||||
aktiv.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (offen) {
|
||||
setzeErgebnis(null)
|
||||
setzeFehler(null)
|
||||
setzeLaeuft(null)
|
||||
}
|
||||
}, [offen])
|
||||
|
||||
const schliessen = () => {
|
||||
if (laeuft) return
|
||||
beiSchliessen()
|
||||
}
|
||||
|
||||
const dateiGewaehlt = async (art: Art, datei: File) => {
|
||||
setzeLaeuft(art)
|
||||
setzeErgebnis(null)
|
||||
setzeFehler(null)
|
||||
let inhalt: string
|
||||
try {
|
||||
inhalt = await datei.text()
|
||||
} catch {
|
||||
if (!aktiv.current) return
|
||||
setzeLaeuft(null)
|
||||
setzeFehler("Die Datei ließ sich nicht lesen.")
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (art === "fahrzeugprofil") {
|
||||
const profil = JSON.parse(inhalt) as Profil
|
||||
await beiProfilGeladen(profil)
|
||||
if (!aktiv.current) return
|
||||
setzeErgebnis({ art, text: ergebnisText(art, {}) })
|
||||
} else {
|
||||
const e = await api.csvImportieren(art, inhalt)
|
||||
if (!aktiv.current) return
|
||||
setzeErgebnis({ art, text: ergebnisText(art, e) })
|
||||
beiFertig()
|
||||
}
|
||||
} catch (err) {
|
||||
if (!aktiv.current) return
|
||||
setzeFehler(
|
||||
art === "fahrzeugprofil"
|
||||
? `Die Datei ließ sich nicht lesen: ${err instanceof Error ? err.message : "unbekannter Fehler"}`
|
||||
: `Der Import ist fehlgeschlagen: ${err instanceof Error ? err.message : "unbekannter Fehler"}`,
|
||||
)
|
||||
} finally {
|
||||
if (aktiv.current) setzeLaeuft(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
/* Blatt statt schwebender Karte - Apples Vorgabe fuer eine vom Nutzer
|
||||
angestossene Aufgabe auf dem Telefon. */
|
||||
<Sheet
|
||||
open={offen}
|
||||
onClose={schliessen}
|
||||
title={modus === "sichern" ? "Datensatz sichern" : "Datensatz laden"}
|
||||
>
|
||||
<p className="dm-fussnote">
|
||||
{modus === "sichern"
|
||||
? "Semikolon als Trennzeichen und deutsches Zahlenformat, damit die Dateien ohne Umweg in Tabellenprogrammen aufgehen."
|
||||
: "Eine zuvor gesicherte, ggf. bearbeitete Datei wieder einspielen — eine Zeile mit bekannter ID aktualisiert nur ihre eigenen Spalten, alles andere bleibt unangetastet."}
|
||||
</p>
|
||||
{ARTEN.map(([art, label]) =>
|
||||
modus === "sichern" ? (
|
||||
<div key={art} style={{ marginTop: 12 }}>
|
||||
<ActionButton
|
||||
onClick={() => exportieren(art, rohprofil, fahrten, tankvorgaenge, wartungsplan)}
|
||||
>
|
||||
{label}
|
||||
{art === "fahrzeugprofil" ? " sichern" : " als CSV"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
) : (
|
||||
<div key={art} style={{ marginTop: 12 }}>
|
||||
<ActionButton
|
||||
onClick={() => eingaben.current[art]?.click()}
|
||||
disabled={laeuft !== null}
|
||||
>
|
||||
{laeuft === art ? "Lädt …" : `${label}${art === "fahrzeugprofil" ? " laden" : " (CSV) laden"}`}
|
||||
</ActionButton>
|
||||
<input
|
||||
ref={(el) => {
|
||||
eingaben.current[art] = el
|
||||
}}
|
||||
type="file"
|
||||
accept={art === "fahrzeugprofil" ? "application/json" : "text/csv"}
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const datei = e.target.files?.[0]
|
||||
e.target.value = ""
|
||||
if (datei) void dateiGewaehlt(art, datei)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
{ergebnis && <p className="dm-importergebnis">{ergebnis.text}</p>}
|
||||
{fehler && <p className="dm-fussnote dm-fussnote--warnung">{fehler}</p>}
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<ActionButton onClick={beiSchliessen} disabled={laeuft !== null}>
|
||||
Fertig
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Ein Datumsfeld, das seinen Wert auch dann anzeigt, wenn er erst später
|
||||
* eintrifft.
|
||||
*
|
||||
* DAS PROBLEM
|
||||
* -----------
|
||||
* Alle Datumsfelder der App hängen an Werten aus dem Datenkontext, und der
|
||||
* füllt sich asynchron: beim ersten Rendern steht dort ein leerer String, den
|
||||
* echten Wert bekommt das Feld erst danach. WebKit — die Engine der iOS-Hülle —
|
||||
* baut den inneren Aufbau eines `<input type="date">` beim ersten Rendern auf
|
||||
* und zieht ihn bei einer späteren Wertänderung von außen nicht zuverlässig
|
||||
* nach; sichtbar blieb „tt.mm.jjjj", und erst ein Antippen brachte das Datum
|
||||
* zum Vorschein (gemeldet vom Eigentümer, 2026-08-31: „erst beim ersten Klick
|
||||
* aufs Datumsfeld erscheinen diese korrekt").
|
||||
*
|
||||
* Verschärft wird das durch den `appearance`-Reset in screens.css, der für die
|
||||
* Box-Optik nötig ist — ohne ihn zeigt iOS sein eigenes, stark abgerundetes
|
||||
* Chrome („gestreckter Kreis statt Box").
|
||||
*
|
||||
* DIE LÖSUNG
|
||||
* ----------
|
||||
* Der Schlüssel wechselt genau einmal: von „leer" auf „gefuellt", sobald der
|
||||
* echte Wert da ist. React baut das Feld an dieser Stelle neu auf, und WebKit
|
||||
* legt seinen inneren Aufbau damit gleich mit dem Wert an.
|
||||
*
|
||||
* Bewusst NICHT der Wert selbst als Schlüssel: dann entstünde das Feld bei
|
||||
* jeder Auswahl neu, verlöre den Fokus und schlösse auf dem Telefon die
|
||||
* Datumsauswahl mitten in der Bedienung. Der Wechsel leer→gefüllt passiert
|
||||
* dagegen genau einmal, und zwar bevor jemand das Feld anfassen kann.
|
||||
*
|
||||
* Das Panel braucht das nicht: dort schreibt jedes `render()` das Markup neu,
|
||||
* das Feld entsteht also ohnehin immer mit seinem Wert.
|
||||
*/
|
||||
|
||||
export function Datumsfeld({
|
||||
wert,
|
||||
beiAenderung,
|
||||
className = "dm-eingabe",
|
||||
...rest
|
||||
}: {
|
||||
wert: string
|
||||
beiAenderung: (wert: string) => void
|
||||
className?: string
|
||||
} & Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
"value" | "onChange" | "type" | "className"
|
||||
>) {
|
||||
return (
|
||||
<input
|
||||
key={wert ? "gefuellt" : "leer"}
|
||||
type="date"
|
||||
className={className}
|
||||
value={wert}
|
||||
onChange={(e) => beiAenderung(e.target.value)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -12,13 +12,28 @@ import { useState } from "react"
|
||||
import { ActionButton, Feld, Tile } from "@audi-dash/ui"
|
||||
|
||||
import { ApiFehler, HassRest, basisUrlNormalisieren, zugangSpeichern } from "../api"
|
||||
import { QrScanner, type QrFund } from "./QrScanner"
|
||||
import { zugangMerken } from "./zugang"
|
||||
|
||||
/* Der Scanner hat nur hier einen Platz: er hilft bei der Ersteinrichtung, und
|
||||
danach gibt es nichts mehr abzutippen. Angeboten wird er nur, wenn das
|
||||
Gerät überhaupt eine Kamera an die App durchreicht - am Rechner wäre er ein
|
||||
Knopf, der zwangsläufig in eine Fehlermeldung läuft. */
|
||||
const kameraVorhanden = () =>
|
||||
typeof navigator !== "undefined" && !!navigator.mediaDevices?.getUserMedia
|
||||
|
||||
export function Einrichtung({ fertig }: { fertig: () => void }) {
|
||||
const [adresse, setzeAdresse] = useState("")
|
||||
const [token, setzeToken] = useState("")
|
||||
const [laeuft, setzeLaeuft] = useState(false)
|
||||
const [fehler, setzeFehler] = useState<string | null>(null)
|
||||
const [scannerOffen, setzeScannerOffen] = useState(false)
|
||||
|
||||
const scanUebernehmen = (fund: QrFund) => {
|
||||
setzeFehler(null)
|
||||
if (fund.art === "adresse") setzeAdresse(fund.wert)
|
||||
else setzeToken(fund.wert)
|
||||
}
|
||||
|
||||
const verbinden = async () => {
|
||||
setzeFehler(null)
|
||||
@@ -92,6 +107,12 @@ export function Einrichtung({ fertig }: { fertig: () => void }) {
|
||||
</Feld>
|
||||
</Tile>
|
||||
|
||||
{kameraVorhanden() && (
|
||||
<div className="dm-einrichtung__knoepfe">
|
||||
<ActionButton onClick={() => setzeScannerOffen(true)}>QR-Code scannen</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fehler && (
|
||||
<p className="dm-einrichtung__fehler" role="alert">
|
||||
{fehler}
|
||||
@@ -99,11 +120,17 @@ export function Einrichtung({ fertig }: { fertig: () => void }) {
|
||||
)}
|
||||
|
||||
<div className="dm-einrichtung__knoepfe">
|
||||
<ActionButton onClick={() => void verbinden()} disabled={laeuft}>
|
||||
<ActionButton variant="primary" onClick={() => void verbinden()} disabled={laeuft}>
|
||||
{laeuft ? "Verbinde …" : "Verbinden"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
<QrScanner
|
||||
offen={scannerOffen}
|
||||
schliessen={() => setzeScannerOffen(false)}
|
||||
gefunden={scanUebernehmen}
|
||||
/>
|
||||
|
||||
<p className="dm-einrichtung__hinweis">
|
||||
Den Token erzeugst du in Home Assistant unter „Profil → Sicherheit → Langlebige
|
||||
Zugriffstoken". Deine Daten bleiben auf deinem Server.
|
||||
|
||||
@@ -9,16 +9,64 @@
|
||||
* ist, statt eine glaubwürdig aussehende Erfindung zu zeigen.
|
||||
*/
|
||||
|
||||
import { Pill, Tile } from "@audi-dash/ui"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { ActionButton, Feld, Fig, Pill, Tile } from "@audi-dash/ui"
|
||||
|
||||
import type { Fahrt } from "../api"
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { datum, dauer, de, deOderStrich, uhrzeit } from "../format"
|
||||
import { datum, dauer, de, streckeTeile, uhrzeit } from "../format"
|
||||
import type { SeitenName } from "../navigation"
|
||||
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
|
||||
import { LOESCH_HINWEIS, LOESCH_TEXT, bestaetigen } from "./bestaetigung"
|
||||
import { FahrtFelderFormular, fahrtFelderAusFahrt, fahrtFelderAuswerten } from "./FahrtFelder"
|
||||
import { Karte } from "./Karte"
|
||||
|
||||
export function FahrtDetail({ id }: { id: string | undefined }) {
|
||||
const { fahrten } = useDaten()
|
||||
/** "YYYY-MM-DDTHH:mm" in Ortszeit für ein <input type="datetime-local">. */
|
||||
function alsDatetimeLocal(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return ""
|
||||
const versatz = d.getTimezoneOffset() * 60_000
|
||||
return new Date(d.getTime() - versatz).toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
export function FahrtDetail({ id, geheZu }: { id: string | undefined; geheZu: (name: SeitenName) => void }) {
|
||||
const { fahrten, api, jetztAktualisieren } = useDaten()
|
||||
const fahrt = fahrten.find((f) => f.trip_id === id)
|
||||
const [bearbeitenOffen, setzeBearbeitenOffen] = useState(false)
|
||||
// Vorgriff auf die Antwort des Backends: der Dienstaufruf laeuft ueber die
|
||||
// Warteschlange und der neue Stand kommt erst mit dem naechsten Push zurueck.
|
||||
// Ohne diesen Zwischenwert bliebe die Pille nach dem Tippen sekundenlang auf
|
||||
// dem alten Text stehen und der Tipp saehe aus, als waere er verloren
|
||||
// gegangen. Das Panel macht dasselbe (dort direkt auf TRIPS, gefolgt von
|
||||
// render()).
|
||||
const [artVorgriff, setzeArtVorgriff] = useState<"privat" | "arbeitsweg" | null>(null)
|
||||
|
||||
/* Ortsangaben zu Start und Ziel - beide kommen mit der Fahrt.
|
||||
|
||||
Bis zum 01.09.2026 loeste dieser Bildschirm die Koordinaten selbst auf und
|
||||
legte sie in seinen eigenen Zwischenspeicher. Jetzt macht das Backend es
|
||||
einmal (geokodierung.py) und speichert das Ergebnis in der Fahrt: jedes
|
||||
Geraet zeigt denselben Ort, ein frisch eingerichtetes Telefon zeigt ihn
|
||||
sofort, und ein geleerter Speicher wirft nichts weg.
|
||||
|
||||
"wird ermittelt …" heisst: die Koordinaten stehen, der Ort noch nicht -
|
||||
das naechste Screening traegt ihn nach. */
|
||||
const ortText = (
|
||||
adresse: string | null | undefined,
|
||||
lat?: number | null,
|
||||
lon?: number | null,
|
||||
) => adresse ?? (lat == null || lon == null ? "unbekannt" : "wird ermittelt …")
|
||||
const startOrt = ortText(fahrt?.start_address, fahrt?.start_lat, fahrt?.start_lon)
|
||||
const zielOrt = ortText(fahrt?.end_address, fahrt?.end_lat, fahrt?.end_lon)
|
||||
|
||||
// Faellt der Vorgriff mit dem echten Wert zusammen, wird er ueberfluessig.
|
||||
// Bleibt er stehen, wuerde eine spaetere Aenderung von aussen (Panel,
|
||||
// zweites Geraet) von einem laengst erledigten Tipp ueberdeckt.
|
||||
const echteArt = fahrt?.art
|
||||
useEffect(() => {
|
||||
if (artVorgriff && echteArt === artVorgriff) setzeArtVorgriff(null)
|
||||
}, [echteArt, artVorgriff])
|
||||
|
||||
if (!fahrt) {
|
||||
return (
|
||||
@@ -29,65 +77,48 @@ export function FahrtDetail({ id }: { id: string | undefined }) {
|
||||
)
|
||||
}
|
||||
|
||||
const art = artVorgriff ?? fahrt.art
|
||||
const artUmschalten = () => {
|
||||
const neu = art === "arbeitsweg" ? "privat" : "arbeitsweg"
|
||||
setzeArtVorgriff(neu)
|
||||
void api.fahrtAktualisieren(fahrt.trip_id, { art: neu }).then(() => jetztAktualisieren())
|
||||
}
|
||||
|
||||
const loeschen = async () => {
|
||||
if (!fahrt) return
|
||||
if (!(await bestaetigen(LOESCH_TEXT.trip, { text: LOESCH_HINWEIS.trip }))) return
|
||||
void api.fahrtLoeschen(fahrt.trip_id)
|
||||
geheZu("trips")
|
||||
}
|
||||
|
||||
if (bearbeitenOffen) {
|
||||
return <FahrtBearbeiten fahrt={fahrt} beiFertig={() => setzeBearbeitenOffen(false)} />
|
||||
}
|
||||
|
||||
const hatPositionen = fahrt.start_lat != null && fahrt.start_lon != null
|
||||
|
||||
/* Aufbau 1:1 wie vTrip(): Karte, dann die randlose Distanz-Kachel mit der
|
||||
52px-Zahl und der Art-Pille darunter, dann eine einzige Wertezeilen-Kachel
|
||||
(Start/Ziel/Start- und Endkilometer/Dauer/Verbrauch) mit „Werte
|
||||
bearbeiten", zuletzt „Fahrt löschen".
|
||||
|
||||
Vorher stand hier eine eigene Kopfzeile mit Datumsbereich und
|
||||
Bearbeiten-Textknopf, die Werte in anderer Reihenfolge und unter anderen
|
||||
Namen („Kilometerstand Beginn/Ende"), eine Zeile „Durchschnitt", die es im
|
||||
Panel nicht gibt, zwei zusätzliche Erklärabsätze und Start/Ziel erst ganz
|
||||
unten unter der Karte. */
|
||||
return (
|
||||
<>
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">
|
||||
{datum(fahrt.ts_start)} · {uhrzeit(fahrt.ts_start)} bis {uhrzeit(fahrt.ts_end)} Uhr
|
||||
</span>
|
||||
<div className="dm-detail__kopf">
|
||||
<div className="dm-detail__zahl">
|
||||
{deOderStrich(fahrt.distance_km, 1)}
|
||||
<span className="dm-detail__einheit">km</span>
|
||||
</div>
|
||||
<div className="dm-detail__marken">
|
||||
{fahrt.art === "arbeitsweg" ? <Pill variant="work">Arbeitsweg</Pill> : <Pill>Privat</Pill>}
|
||||
{fahrt.status === "offen" && <Pill>noch offen</Pill>}
|
||||
{fahrt.source === "manual" && <Pill>selbst eingetragen</Pill>}
|
||||
</div>
|
||||
</div>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile label="Dauer" wert={dauer(fahrt.duration_s)} />
|
||||
<Wertzeile
|
||||
label="Kilometerstand Beginn"
|
||||
wert={fahrt.odo_start != null ? `${de(fahrt.odo_start)} km` : "—"}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Kilometerstand Ende"
|
||||
wert={fahrt.odo_end != null ? `${de(fahrt.odo_end)} km` : "—"}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Durchschnitt"
|
||||
wert={
|
||||
fahrt.distance_km != null && fahrt.duration_s > 0
|
||||
? `${de((fahrt.distance_km / fahrt.duration_s) * 3600, 1)} km/h`
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Tile>
|
||||
|
||||
{fahrt.status === "offen" && (
|
||||
<p className="dm-fussnote">
|
||||
Diese Fahrt wartet noch auf den Kilometerstand. Das Fahrzeug meldet ihn oft erst bei der
|
||||
nächsten Fahrt — die Strecke wird dann automatisch ergänzt.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Strecke</span>
|
||||
<Tile className="dm-kartenkachel">
|
||||
{hatPositionen ? (
|
||||
<Karte
|
||||
start={{ lat: fahrt.start_lat!, lon: fahrt.start_lon! }}
|
||||
{...(fahrt.end_lat != null && fahrt.end_lon != null
|
||||
? { ziel: { lat: fahrt.end_lat, lon: fahrt.end_lon } }
|
||||
: {})}
|
||||
{...(fahrt.route && fahrt.route.length >= 2 ? { route: fahrt.route } : {})}
|
||||
startText={fahrt.start_address ?? fahrt.start_stadt ?? ""}
|
||||
zielText={fahrt.end_address ?? fahrt.end_stadt ?? ""}
|
||||
/>
|
||||
) : (
|
||||
<Leerzustand
|
||||
@@ -95,17 +126,170 @@ export function FahrtDetail({ id }: { id: string | undefined }) {
|
||||
text="Das Fahrzeug liefert derzeit keine Positionsdaten. Sobald es das tut, erscheint hier die gefahrene Strecke auf der Karte."
|
||||
/>
|
||||
)}
|
||||
{(fahrt.start_address || fahrt.end_address) && (
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile label="Start" wert={fahrt.start_address ?? "—"} />
|
||||
<Wertzeile label="Ziel" wert={fahrt.end_address ?? "—"} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Tile>
|
||||
|
||||
<Tile variant="flat" className="dm-distanzkachel">
|
||||
<span className="ads-eyebrow">Distanz</span>
|
||||
<div style={{ marginTop: "10px" }}>
|
||||
<Fig
|
||||
value={(streckeTeile(fahrt.distance_km) ?? { wert: "–" }).wert}
|
||||
unit={(streckeTeile(fahrt.distance_km) ?? { einheit: "km" }).einheit}
|
||||
size={52}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: "15px" }}>
|
||||
{/* Knopf, nicht Anzeige: im Panel schaltet dieselbe Pille seit jeher
|
||||
zwischen Privat und Arbeitsweg um (data-art, fahrt_aktualisieren).
|
||||
In der App war sie bis 2026-08-31 ein totes <span>. */}
|
||||
<Pill onClick={artUmschalten} ariaLabel={`Art der Fahrt umschalten, derzeit ${art === "arbeitsweg" ? "Arbeitsweg" : "Privat"}`}>
|
||||
{art === "arbeitsweg" ? "Arbeitsweg" : "Privat"}
|
||||
</Pill>
|
||||
</div>
|
||||
</Tile>
|
||||
|
||||
<Tile>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile
|
||||
label="Start"
|
||||
wert={`${datum(fahrt.ts_start)} · ${uhrzeit(fahrt.ts_start)} Uhr`}
|
||||
zusatz={startOrt}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Ziel"
|
||||
wert={`${datum(fahrt.ts_end)} · ${uhrzeit(fahrt.ts_end)} Uhr`}
|
||||
zusatz={zielOrt}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Startkilometer"
|
||||
wert={fahrt.odo_start != null ? `${de(fahrt.odo_start)} km` : "noch offen"}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Endkilometer"
|
||||
wert={fahrt.odo_end != null ? `${de(fahrt.odo_end)} km` : "noch offen"}
|
||||
/>
|
||||
{/* Der Dongle kommt aus dem Schlaf nur ueber seinen Beschleunigungssensor hoch
|
||||
und braucht dafuer gemessene 28 s bis 19,5 min. Was in dieser Zeit gefahren
|
||||
wird, zeichnet er nicht auf. Die Kilometer holt das Screening aus dem
|
||||
Endkilometerstand der Vorgaengerin zurueck (verlauf.aufzeichnungsluecke_km),
|
||||
die Route ist unwiederbringlich weg. Deshalb steht es hier: eine Fahrt, die
|
||||
auf den ersten Metern keine Spur hat, soll nicht so aussehen, als waere sie
|
||||
vollstaendig aufgezeichnet. */}
|
||||
{fahrt.luecke_km ? (
|
||||
<Wertzeile
|
||||
label="Nicht aufgezeichnet"
|
||||
wert={
|
||||
fahrt.luecke_unsicher
|
||||
? `${de(fahrt.luecke_km)} km am Anfang · Zuordnung unsicher`
|
||||
: `${de(fahrt.luecke_km)} km am Anfang`
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<Wertzeile label="Dauer" wert={dauer(fahrt.duration_s)} />
|
||||
<Wertzeile
|
||||
label="Ø Geschwindigkeit"
|
||||
wert={
|
||||
fahrt.avg_speed_kmh != null
|
||||
? `${de(fahrt.avg_speed_kmh, 1)} km/h`
|
||||
: "liegt nicht vor"
|
||||
}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Höchstgeschwindigkeit"
|
||||
wert={
|
||||
fahrt.vmax_kmh != null ? `${de(fahrt.vmax_kmh)} km/h` : "liegt nicht vor"
|
||||
}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Verbrauch"
|
||||
wert={
|
||||
fahrt.verbrauch_l_100km != null
|
||||
? `${de(fahrt.verbrauch_l_100km, 1)} l/100 km`
|
||||
: "liegt nicht vor"
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<ActionButton onClick={() => setzeBearbeitenOffen(true)}>Werte bearbeiten</ActionButton>
|
||||
</Tile>
|
||||
|
||||
<ActionButton variant="destructive" onClick={() => void loeschen()}>
|
||||
Fahrt löschen
|
||||
</ActionButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function FahrtBearbeiten({ fahrt, beiFertig }: { fahrt: Fahrt; beiFertig: () => void }) {
|
||||
const { api } = useDaten()
|
||||
const [start, setzeStart] = useState(alsDatetimeLocal(fahrt.ts_start))
|
||||
const [ende, setzeEnde] = useState(alsDatetimeLocal(fahrt.ts_end))
|
||||
const [felder, setzeFelder] = useState(fahrtFelderAusFahrt(fahrt))
|
||||
const [fehler, setzeFehler] = useState<string | null>(null)
|
||||
const [laeuft, setzeLaeuft] = useState(false)
|
||||
|
||||
const speichern = async () => {
|
||||
setzeFehler(null)
|
||||
if (!start || !ende) {
|
||||
setzeFehler("Bitte Beginn und Ende angeben.")
|
||||
return
|
||||
}
|
||||
if (new Date(ende) <= new Date(start)) {
|
||||
setzeFehler("Das Ende muss nach dem Beginn liegen.")
|
||||
return
|
||||
}
|
||||
setzeLaeuft(true)
|
||||
try {
|
||||
await api.fahrtAktualisieren(fahrt.trip_id, {
|
||||
ts_start: new Date(start).toISOString(),
|
||||
ts_end: new Date(ende).toISOString(),
|
||||
...fahrtFelderAuswerten(felder),
|
||||
})
|
||||
beiFertig()
|
||||
} catch (ursache) {
|
||||
setzeFehler(ursache instanceof Error ? ursache.message : String(ursache))
|
||||
} finally {
|
||||
setzeLaeuft(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tile>
|
||||
<div className="dm-abschnitt" style={{ marginBottom: 0 }}>
|
||||
<span className="ads-eyebrow">Fahrt bearbeiten</span>
|
||||
<button type="button" className="dm-textknopf" onClick={beiFertig} disabled={laeuft}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
<Feld label="Beginn">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
type="datetime-local"
|
||||
value={start}
|
||||
onChange={(e) => setzeStart(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Ende">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
type="datetime-local"
|
||||
value={ende}
|
||||
onChange={(e) => setzeEnde(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
<FahrtFelderFormular werte={felder} setzeWerte={setzeFelder} />
|
||||
{fehler && (
|
||||
<p className="dm-fehler" role="alert">
|
||||
{fehler}
|
||||
</p>
|
||||
)}
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
|
||||
{laeuft ? "Speichere …" : "Speichern"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Tile>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Die Felder einer Fahrt jenseits von Zeitpunkt und Dauer - geteilt zwischen
|
||||
* dem Neuanlegen (Fahrten.tsx) und dem Bearbeiten (FahrtDetail.tsx), damit
|
||||
* beide Formulare nicht auseinanderlaufen. Vorlage: `fahrtFelder()` im
|
||||
* Panel (audi-dashboard-app.js).
|
||||
*/
|
||||
|
||||
import { Feld } from "@audi-dash/ui"
|
||||
|
||||
import type { FahrtFelder as FahrtFelderApi } from "../api"
|
||||
|
||||
export interface FahrtFelderEingabe {
|
||||
art: "privat" | "arbeitsweg"
|
||||
startOrt: string
|
||||
zielOrt: string
|
||||
odoStart: string
|
||||
odoEnde: string
|
||||
distanz: string
|
||||
}
|
||||
|
||||
export const LEERE_FAHRT_FELDER: FahrtFelderEingabe = {
|
||||
art: "privat",
|
||||
startOrt: "",
|
||||
zielOrt: "",
|
||||
odoStart: "",
|
||||
odoEnde: "",
|
||||
distanz: "",
|
||||
}
|
||||
|
||||
/** Vorbelegung aus einer bestehenden Fahrt fürs Bearbeiten-Formular. */
|
||||
export function fahrtFelderAusFahrt(fahrt: {
|
||||
art: "privat" | "arbeitsweg"
|
||||
start_address?: string | null
|
||||
end_address?: string | null
|
||||
odo_start?: number | null
|
||||
odo_end?: number | null
|
||||
distance_km?: number | null
|
||||
}): FahrtFelderEingabe {
|
||||
return {
|
||||
art: fahrt.art,
|
||||
startOrt: fahrt.start_address ?? "",
|
||||
zielOrt: fahrt.end_address ?? "",
|
||||
odoStart: fahrt.odo_start != null ? String(fahrt.odo_start) : "",
|
||||
odoEnde: fahrt.odo_end != null ? String(fahrt.odo_end) : "",
|
||||
distanz: fahrt.distance_km != null ? String(fahrt.distance_km) : "",
|
||||
}
|
||||
}
|
||||
|
||||
/** Wandelt die Formulareingabe in die Felder um, die das Backend erwartet -
|
||||
leere Felder werden als "nicht angegeben" (undefined) übertragen, nicht
|
||||
als 0, sonst würde ein leeres Kilometerfeld einen echten Wert überschreiben. */
|
||||
export function fahrtFelderAuswerten(
|
||||
eingabe: FahrtFelderEingabe,
|
||||
): Pick<FahrtFelderApi, "art" | "start_ort" | "ziel_ort" | "odo_start" | "odo_end" | "distanz"> {
|
||||
const zahl = (wert: string): number | null => {
|
||||
if (wert.trim() === "") return null
|
||||
const n = Number(wert.replace(",", "."))
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
return {
|
||||
art: eingabe.art,
|
||||
start_ort: eingabe.startOrt.trim() || null,
|
||||
ziel_ort: eingabe.zielOrt.trim() || null,
|
||||
odo_start: zahl(eingabe.odoStart),
|
||||
odo_end: zahl(eingabe.odoEnde),
|
||||
distanz: zahl(eingabe.distanz),
|
||||
}
|
||||
}
|
||||
|
||||
export function FahrtFelderFormular({
|
||||
werte,
|
||||
setzeWerte,
|
||||
}: {
|
||||
werte: FahrtFelderEingabe
|
||||
setzeWerte: (aendern: (alt: FahrtFelderEingabe) => FahrtFelderEingabe) => void
|
||||
}) {
|
||||
const feld = <K extends keyof FahrtFelderEingabe>(schluessel: K) => ({
|
||||
value: werte[schluessel],
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setzeWerte((alt) => ({ ...alt, [schluessel]: e.target.value })),
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Feld label="Art">
|
||||
<select
|
||||
className="dm-auswahl"
|
||||
value={werte.art}
|
||||
onChange={(e) =>
|
||||
setzeWerte((alt) => ({ ...alt, art: e.target.value as "privat" | "arbeitsweg" }))
|
||||
}
|
||||
>
|
||||
<option value="privat">Privat</option>
|
||||
<option value="arbeitsweg">Arbeitsweg</option>
|
||||
</select>
|
||||
</Feld>
|
||||
<Feld label="Startort">
|
||||
<input className="dm-eingabe" placeholder="unbekannt" {...feld("startOrt")} />
|
||||
</Feld>
|
||||
<Feld label="Zielort">
|
||||
<input className="dm-eingabe" placeholder="unbekannt" {...feld("zielOrt")} />
|
||||
</Feld>
|
||||
<Feld label="Startkilometer" unit="km">
|
||||
<input className="dm-eingabe" inputMode="numeric" placeholder="wird ergänzt" {...feld("odoStart")} />
|
||||
</Feld>
|
||||
<Feld label="Endkilometer" unit="km">
|
||||
<input className="dm-eingabe" inputMode="numeric" placeholder="wird ergänzt" {...feld("odoEnde")} />
|
||||
</Feld>
|
||||
<Feld label="Gefahrene Distanz" unit="km" last>
|
||||
<input className="dm-eingabe" inputMode="decimal" placeholder="aus km-Stand" {...feld("distanz")} />
|
||||
</Feld>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -5,103 +5,160 @@
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import { Accordion, ActionButton, Feld, Pill, Tile } from "@audi-dash/ui"
|
||||
import { Accordion, ActionButton, Feld, 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 { datum, de, streckeText, summe } from "../format"
|
||||
import type { SeitenName } from "../navigation"
|
||||
import { Leerzustand } from "./bausteine"
|
||||
import { Blattzeile, Leerzustand } from "./bausteine"
|
||||
import { FahrtFelderFormular, LEERE_FAHRT_FELDER, fahrtFelderAuswerten } from "./FahrtFelder"
|
||||
import type { FahrtFelderEingabe } from "./FahrtFelder"
|
||||
import { LOESCH_HINWEIS } from "./bestaetigung"
|
||||
import { Zeilenmenue } from "./Zeilenmenue"
|
||||
|
||||
/** Vorlage: strecke() im Panel - "Start → Ziel", nur eine Seite, oder
|
||||
"Fahrt ohne Ortsangabe". Trug bisher in der Liste hier gar nichts bei
|
||||
(nur auf der Einzelfahrt-Seite sichtbar) - im Panel ist das die
|
||||
führende Information jeder Fahrtzeile. */
|
||||
export function strecke(fahrt: {
|
||||
start_address?: string | null
|
||||
end_address?: string | null
|
||||
start_stadt?: string | null
|
||||
end_stadt?: string | null
|
||||
edited_fields?: string[] | null
|
||||
}): string | null {
|
||||
// Der Ortsname kommt mit der Fahrt (start_stadt), aufgeloest im Backend -
|
||||
// kein Abruf, kein Zwischenspeicher, auf jedem Geraet derselbe Wert.
|
||||
//
|
||||
// Eine VON HAND eingetragene Anschrift sticht ihn: wer "Zuhause" eintraegt,
|
||||
// will das auch in der Liste lesen und nicht den Ortsnamen dazu. Erkennbar
|
||||
// an edited_fields - genau dafuer fuehrt das Backend die Liste.
|
||||
const handisch = new Set(fahrt.edited_fields ?? [])
|
||||
const ortsname = (seite: "start" | "end"): string | null => {
|
||||
const adresse = seite === "start" ? fahrt.start_address : fahrt.end_address
|
||||
if (adresse && handisch.has(`${seite}_address`)) return adresse
|
||||
const stadt = seite === "start" ? fahrt.start_stadt : fahrt.end_stadt
|
||||
return stadt ?? adresse ?? null
|
||||
}
|
||||
const a = ortsname("start")
|
||||
const b = ortsname("end")
|
||||
if (!a && !b) return "Fahrt ohne Ortsangabe"
|
||||
if (!b) return a ?? null
|
||||
if (!a) return b
|
||||
return `${a} → ${b}`
|
||||
}
|
||||
|
||||
/** artText() im Panel: erster Buchstabe groß, sonst unverändert. */
|
||||
function artText(art: string | null | undefined): string {
|
||||
return art ? art.charAt(0).toUpperCase() + art.slice(1) : ""
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
/* Aufbau 1:1 wie vTrips(): eine einzige Kachel um alle Jahre, die
|
||||
Fahrtenzahl steht in der Jahreszeile (nicht als eigene Überschrift über
|
||||
der Liste), die Monatszeile nennt nur den Monat, und das Formular steht
|
||||
unter der Liste statt darüber. Die Zeilen selbst sind Blattzeilen mit der
|
||||
Strecke als Hauptangabe und "Datum · Art" darunter — hier standen bisher
|
||||
die Kilometer vorn und alles andere in einer langen Fußzeile, dazu zwei
|
||||
Pillen (Arbeitsweg/offen), die es im Panel nicht gibt: die Art steht dort
|
||||
im Zusatz der Beschriftung, der Status im Zusatz des Werts. */
|
||||
return (
|
||||
<>
|
||||
<div className="dm-abschnitt">
|
||||
<span className="ads-eyebrow">
|
||||
{fahrten.length === 0
|
||||
? "Keine Fahrten"
|
||||
: `${de(fahrten.length)} Fahrt${fahrten.length === 1 ? "" : "en"}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="dm-textknopf"
|
||||
onClick={() => setzeFormularOffen((offen) => !offen)}
|
||||
>
|
||||
{formularOffen ? "Abbrechen" : "Fahrt eintragen"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{formularOffen && <FahrtFormular beiFertig={() => setzeFormularOffen(false)} />}
|
||||
|
||||
{fahrten.length === 0 && !formularOffen && (
|
||||
<Leerzustand
|
||||
titel="Noch keine Fahrten"
|
||||
text="Die App erkennt Fahrten automatisch, sobald sich das Fahrzeug meldet. Bis dahin kannst du Fahrten auch selbst eintragen."
|
||||
aktion={{ beschriftung: "Fahrt eintragen", beiKlick: () => setzeFormularOffen(true) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{gruppen.map((jahr) => (
|
||||
<Accordion
|
||||
key={jahr.jahr}
|
||||
title={String(jahr.jahr)}
|
||||
summary={`${de(summe(jahr.eintraege, (f) => f.distance_km ?? 0))} km`}
|
||||
defaultOpen={jahr === gruppen[0]}
|
||||
>
|
||||
{jahr.monate.map((monat) => (
|
||||
{gruppen.length === 0 ? (
|
||||
<Tile>
|
||||
<Leerzustand
|
||||
titel="Noch keine Fahrten"
|
||||
text="Fahrten werden erkannt, sobald der Motor gestartet wird. Bis dahin lässt sich jede Fahrt von Hand erfassen."
|
||||
/>
|
||||
</Tile>
|
||||
) : (
|
||||
<Tile className="dm-liste">
|
||||
{gruppen.map((jahr) => (
|
||||
<Accordion
|
||||
key={monat.schluessel}
|
||||
level={2}
|
||||
title={monatJahr(new Date(monat.jahr, monat.monat, 1))}
|
||||
summary={`${de(summe(monat.eintraege, (f) => f.distance_km ?? 0))} km`}
|
||||
defaultOpen={monat === jahr.monate[0]}
|
||||
key={jahr.jahr}
|
||||
title={String(jahr.jahr)}
|
||||
summary={
|
||||
<>
|
||||
{streckeText(summe(jahr.eintraege, (f) => f.distance_km ?? 0))}
|
||||
<br />
|
||||
{jahr.eintraege.length} Fahrt{jahr.eintraege.length === 1 ? "" : "en"}
|
||||
</>
|
||||
}
|
||||
defaultOpen={jahr === gruppen[0]}
|
||||
>
|
||||
{monat.eintraege.map((fahrt) => (
|
||||
<Zeilenmenue
|
||||
key={fahrt.trip_id}
|
||||
loeschFrage={`Fahrt vom ${datum(fahrt.ts_start)} löschen?`}
|
||||
beiLoeschen={() => void api.fahrtLoeschen(fahrt.trip_id)}
|
||||
kinder={
|
||||
<button
|
||||
type="button"
|
||||
className="dm-listenzeile"
|
||||
onClick={() => geheZu("trip", fahrt.trip_id)}
|
||||
>
|
||||
<span className="dm-listenzeile__haupt">
|
||||
<span className="dm-listenzeile__titel">
|
||||
{deOderStrich(fahrt.distance_km, 1)} km
|
||||
</span>
|
||||
<span className="dm-listenzeile__unter">
|
||||
{datum(fahrt.ts_start)} · {uhrzeit(fahrt.ts_start)} Uhr ·{" "}
|
||||
{dauer(fahrt.duration_s)}
|
||||
</span>
|
||||
</span>
|
||||
{fahrt.art === "arbeitsweg" && <Pill variant="work">Arbeitsweg</Pill>}
|
||||
{fahrt.status === "offen" && <Pill>offen</Pill>}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
{jahr.monate.map((monat) => (
|
||||
<Accordion
|
||||
key={monat.schluessel}
|
||||
level={2}
|
||||
title={monatName(monat.jahr, monat.monat)}
|
||||
summary={streckeText(summe(monat.eintraege, (f) => f.distance_km ?? 0))}
|
||||
defaultOpen={monat === jahr.monate[0]}
|
||||
>
|
||||
{monat.eintraege.map((fahrt, i) => (
|
||||
<Zeilenmenue
|
||||
key={fahrt.trip_id}
|
||||
loeschFrage={`Fahrt vom ${datum(fahrt.ts_start)} löschen?`}
|
||||
loeschHinweis={LOESCH_HINWEIS.trip}
|
||||
beiLoeschen={() => void api.fahrtLoeschen(fahrt.trip_id)}
|
||||
kinder={
|
||||
<Blattzeile
|
||||
label={strecke(fahrt)}
|
||||
labelZusatz={`${datum(fahrt.ts_start)} · ${artText(fahrt.art)}`}
|
||||
wert={streckeText(fahrt.distance_km, "—")}
|
||||
wertZusatz={
|
||||
/* Kein Statuswort mehr: "vollstaendig" sagt dem Nutzer nichts,
|
||||
und "offen" verspricht Daten, die bei einer laengst
|
||||
abgeschlossenen Fahrt nie mehr kommen. Fehlt der Wert,
|
||||
steht dort ein Strich. */
|
||||
fahrt.verbrauch_l_100km != null
|
||||
? `${de(fahrt.verbrauch_l_100km, 1)} l/100 km`
|
||||
: "—"
|
||||
}
|
||||
beiKlick={() => geheZu("trip", fahrt.trip_id)}
|
||||
letzte={i === monat.eintraege.length - 1}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Accordion>
|
||||
))}
|
||||
</Accordion>
|
||||
))}
|
||||
</Accordion>
|
||||
))}
|
||||
</Tile>
|
||||
)}
|
||||
|
||||
{formularOffen && <FahrtFormular beiFertig={() => setzeFormularOffen(false)} />}
|
||||
|
||||
<ActionButton
|
||||
variant={formularOffen ? "default" : "primary"}
|
||||
onClick={() => setzeFormularOffen((offen) => !offen)}
|
||||
>
|
||||
{formularOffen ? "Abbrechen" : "Fahrt erfassen"}
|
||||
</ActionButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Manuelles Eintragen — für Fahrten, die die automatische Erkennung verpasst hat. */
|
||||
/** Monatsname allein, wie im Panel (`toLocaleDateString({month:"long"})`). */
|
||||
function monatName(jahr: number, monat: number): string {
|
||||
return new Date(jahr, monat, 1).toLocaleDateString("de-DE", { month: "long" })
|
||||
}
|
||||
|
||||
/** Manuelles Eintragen — für Fahrten, die die automatische Erkennung verpasst hat.
|
||||
Dieselben Felder wie beim Bearbeiten einer bestehenden Fahrt (siehe
|
||||
FahrtFelderFormular in FahrtDetail.tsx) - beide teilen sich deshalb die
|
||||
Feldgruppe. */
|
||||
function FahrtFormular({ beiFertig }: { beiFertig: () => void }) {
|
||||
const { api } = useDaten()
|
||||
const [start, setzeStart] = useState("")
|
||||
const [ende, setzeEnde] = useState("")
|
||||
const [art, setzeArt] = useState<"privat" | "arbeitsweg">("privat")
|
||||
const [felder, setzeFelder] = useState<FahrtFelderEingabe>(LEERE_FAHRT_FELDER)
|
||||
const [fehler, setzeFehler] = useState<string | null>(null)
|
||||
const [laeuft, setzeLaeuft] = useState(false)
|
||||
|
||||
@@ -117,7 +174,11 @@ function FahrtFormular({ beiFertig }: { beiFertig: () => void }) {
|
||||
}
|
||||
setzeLaeuft(true)
|
||||
try {
|
||||
await api.fahrtAnlegen(new Date(start).toISOString(), new Date(ende).toISOString(), art)
|
||||
await api.fahrtAnlegen({
|
||||
ts_start: new Date(start).toISOString(),
|
||||
ts_end: new Date(ende).toISOString(),
|
||||
...fahrtFelderAuswerten(felder),
|
||||
})
|
||||
beiFertig()
|
||||
} catch (ursache) {
|
||||
setzeFehler(ursache instanceof Error ? ursache.message : String(ursache))
|
||||
@@ -128,7 +189,7 @@ function FahrtFormular({ beiFertig }: { beiFertig: () => void }) {
|
||||
|
||||
return (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Fahrt eintragen</span>
|
||||
<span className="ads-eyebrow">Neue Fahrt</span>
|
||||
<Feld label="Beginn">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
@@ -145,27 +206,16 @@ function FahrtFormular({ beiFertig }: { beiFertig: () => void }) {
|
||||
onChange={(e) => setzeEnde(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld label="Art" last>
|
||||
<select
|
||||
className="dm-auswahl"
|
||||
value={art}
|
||||
onChange={(e) => setzeArt(e.target.value as "privat" | "arbeitsweg")}
|
||||
>
|
||||
<option value="privat">Privat</option>
|
||||
<option value="arbeitsweg">Arbeitsweg</option>
|
||||
</select>
|
||||
</Feld>
|
||||
<FahrtFelderFormular werte={felder} setzeWerte={setzeFelder} />
|
||||
{fehler && (
|
||||
<p className="dm-fehler" role="alert">
|
||||
{fehler}
|
||||
</p>
|
||||
)}
|
||||
<p className="dm-fussnote">
|
||||
Die Strecke ergänzt die App selbst, sobald sie den Kilometerstand zu Beginn und Ende der
|
||||
Fahrt kennt.
|
||||
</p>
|
||||
{/* Ohne Erklärabsatz: das Panel führt im Neuanlage-Formular nur die
|
||||
Felder und die Knöpfe. */}
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
|
||||
<ActionButton variant="primary" onClick={() => void speichern()} disabled={laeuft}>
|
||||
{laeuft ? "Speichere …" : "Speichern"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
/**
|
||||
* Fahrzeugdaten: Identität, technisches Datenblatt, Ausstattung.
|
||||
* Vorlage: `vIdent()` im alten Panel. Rein anzeigend — die Inhalte pflegt
|
||||
* man im Profil, nicht hier.
|
||||
* Vorlage: `vIdent()`/`vIdentBearbeiten()` im alten Panel.
|
||||
*
|
||||
* „Daten bearbeiten" öffnet dieselbe Bearbeitungsansicht wie dort: die Werte
|
||||
* des Datenblatts und die Ausstattungslisten, jeweils mit „×" zum Entfernen
|
||||
* und „+ Position hinzufügen". Die Kopfdaten (Modell, Kennzeichen, Ausführung,
|
||||
* Erstzulassung, FIN) bleiben bewusst außen vor — die pflegt „Fahrzeug
|
||||
* einrichten" in den Einstellungen.
|
||||
*/
|
||||
|
||||
import { Accordion, Tile } from "@audi-dash/ui"
|
||||
import { useState } from "react"
|
||||
|
||||
import { ActionButton, Feld, Tile } from "@audi-dash/ui"
|
||||
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { datum, de } from "../format"
|
||||
import { datum } from "../format"
|
||||
import { Wertzeile, Werteliste } from "./bausteine"
|
||||
|
||||
interface TechnikGruppe {
|
||||
@@ -23,6 +30,7 @@ interface AusstattungGruppe {
|
||||
|
||||
export function Fahrzeugdaten() {
|
||||
const { fahrzeug, einstellungen } = useDaten()
|
||||
const [bearbeiten, setzeBearbeiten] = useState(false)
|
||||
if (!fahrzeug || !einstellungen) return null
|
||||
|
||||
const technik = Array.isArray(fahrzeug.technik)
|
||||
@@ -32,72 +40,257 @@ export function Fahrzeugdaten() {
|
||||
? (fahrzeug.ausstattung as AusstattungGruppe[])
|
||||
: []
|
||||
|
||||
if (bearbeiten) {
|
||||
return (
|
||||
<DatenBearbeiten
|
||||
technik={technik}
|
||||
ausstattung={ausstattung}
|
||||
beiFertig={() => setzeBearbeiten(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/* Aufbau 1:1 wie vIdent(): eine Kachel je Technik- und je
|
||||
Ausstattungsgruppe, alle Angaben unmittelbar sichtbar. Bis 2026-08-30
|
||||
steckten sie hier in zugeklappten Aufklapp-Elementen — auf dem Bildschirm
|
||||
blieben von über 3 000 Zeichen Datenblatt nur die Gruppennamen übrig.
|
||||
Auch die Kopfkachel wich ab: sie hieß „Identität" statt „Fahrzeug", zeigte
|
||||
„Hauptuntersuchung fällig" und „Kilometerstand", die das Panel hier nicht
|
||||
führt, und ließ „Motor und Leistung" sowie „Zulassung" weg. */
|
||||
return (
|
||||
<>
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Identität</span>
|
||||
<span className="ads-eyebrow">Fahrzeug</span>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile label="Modell" wert={einstellungen.fahrzeugtitel || "—"} />
|
||||
<Wertzeile label="Ausführung" wert={einstellungen.ausfuehrung || fahrzeug.details || "—"} />
|
||||
<Wertzeile label="Fahrgestellnummer" wert={fahrzeug.fin || "—"} />
|
||||
<Wertzeile label="Kennzeichen" wert={einstellungen.kennzeichen || "—"} />
|
||||
<Wertzeile label="Modell" wert={einstellungen.fahrzeugtitel || "–"} />
|
||||
<Wertzeile
|
||||
label="Ausführung"
|
||||
wert={einstellungen.ausfuehrung || "noch offen"}
|
||||
/>
|
||||
<Wertzeile label="FIN" wert={fahrzeug.fin || "–"} />
|
||||
<Wertzeile label="Motor und Leistung" wert={fahrzeug.details || "–"} />
|
||||
<Wertzeile
|
||||
label="Erstzulassung"
|
||||
wert={fahrzeug.erstzulassung ? datum(fahrzeug.erstzulassung) : "—"}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Hauptuntersuchung fällig"
|
||||
wert={fahrzeug.hu ? datum(fahrzeug.hu) : "—"}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Kilometerstand"
|
||||
wert={fahrzeug.odoBekannt ? `${de(fahrzeug.odo)} km` : "—"}
|
||||
wert={fahrzeug.erstzulassung ? datum(fahrzeug.erstzulassung) : "–"}
|
||||
/>
|
||||
<Wertzeile label="Kennzeichen" wert={einstellungen.kennzeichen || "–"} />
|
||||
<Wertzeile label="Zulassung" wert="Deutschland" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Tile>
|
||||
|
||||
{technik.length > 0 && (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Technische Daten</span>
|
||||
{technik.map((gruppe, i) => (
|
||||
<Accordion key={gruppe.gruppe ?? i} title={gruppe.gruppe ?? "Weitere Angaben"}>
|
||||
<Werteliste
|
||||
kinder={(gruppe.posten ?? []).map(([bezeichnung, wert]) => (
|
||||
<Wertzeile key={bezeichnung} label={bezeichnung} wert={wert} />
|
||||
))}
|
||||
{technik.map((gruppe, i) => (
|
||||
<Tile key={gruppe.gruppe ?? i}>
|
||||
<span className="ads-eyebrow">{gruppe.gruppe ?? "Weitere Angaben"}</span>
|
||||
<Werteliste
|
||||
kinder={(gruppe.posten ?? []).map(([bezeichnung, wert]) => (
|
||||
<Wertzeile
|
||||
key={bezeichnung}
|
||||
label={bezeichnung}
|
||||
wert={wert ? wert : <span className="dm-offen">noch offen</span>}
|
||||
/>
|
||||
</Accordion>
|
||||
))}
|
||||
))}
|
||||
/>
|
||||
</Tile>
|
||||
)}
|
||||
))}
|
||||
|
||||
{ausstattung.length > 0 && (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Ausstattung</span>
|
||||
{ausstattung.map((gruppe, i) => (
|
||||
<Accordion
|
||||
key={gruppe.gruppe ?? i}
|
||||
title={gruppe.gruppe ?? "Weitere Ausstattung"}
|
||||
summary={`${(gruppe.sonder?.length ?? 0) + (gruppe.serie?.length ?? 0)}`}
|
||||
>
|
||||
<ul className="dm-liste">
|
||||
{(gruppe.sonder ?? []).map((posten) => (
|
||||
<li key={posten}>{posten}</li>
|
||||
))}
|
||||
{(gruppe.serie ?? []).map((posten) => (
|
||||
<li key={posten} className="dm-liste__serie">
|
||||
{posten}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Accordion>
|
||||
))}
|
||||
{ausstattung.map((gruppe, i) => (
|
||||
<Tile key={gruppe.gruppe ?? i}>
|
||||
<span className="ads-eyebrow">{gruppe.gruppe ?? "Weitere Ausstattung"}</span>
|
||||
<Werteliste
|
||||
kinder={(gruppe.sonder ?? []).map((posten) => (
|
||||
<Wertzeile key={posten} label={posten} wert="" />
|
||||
))}
|
||||
/>
|
||||
</Tile>
|
||||
)}
|
||||
))}
|
||||
|
||||
<ActionButton onClick={() => setzeBearbeiten(true)}>Daten bearbeiten</ActionButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Bearbeitungsansicht, Zuschnitt wie `vIdentBearbeiten()` im Panel. */
|
||||
function DatenBearbeiten({
|
||||
technik,
|
||||
ausstattung,
|
||||
beiFertig,
|
||||
}: {
|
||||
technik: TechnikGruppe[]
|
||||
ausstattung: AusstattungGruppe[]
|
||||
beiFertig: () => void
|
||||
}) {
|
||||
const { fahrzeug, profilSpeichern } = useDaten()
|
||||
const [entwurfTechnik, setzeEntwurfTechnik] = useState<TechnikGruppe[]>(() =>
|
||||
technik.map((g) => ({ ...g, posten: (g.posten ?? []).map((p) => [p[0], p[1]] as [string, string]) })),
|
||||
)
|
||||
const [entwurfAusstattung, setzeEntwurfAusstattung] = useState<AusstattungGruppe[]>(() =>
|
||||
ausstattung.map((g) => ({ ...g, sonder: [...(g.sonder ?? [])] })),
|
||||
)
|
||||
const [laeuft, setzeLaeuft] = useState(false)
|
||||
|
||||
if (!fahrzeug) return null
|
||||
|
||||
/* Das Panel speichert jedes Feld sofort; hier folgt der Bildschirm dem
|
||||
Muster der übrigen Formulare dieser App — Entwurf plus einem Knopf, der
|
||||
schreibt und zurückführt (Paritätsregel: gleiches Verhalten, eigenes
|
||||
Idiom). Bei rund 50 Feldern spart das ebenso viele Schreibvorgänge auf
|
||||
das gesamte Profil. */
|
||||
const speichern = async () => {
|
||||
setzeLaeuft(true)
|
||||
try {
|
||||
await profilSpeichern({
|
||||
fahrzeug: {
|
||||
...fahrzeug,
|
||||
technik: entwurfTechnik as unknown as typeof fahrzeug.technik,
|
||||
ausstattung: entwurfAusstattung as unknown as typeof fahrzeug.ausstattung,
|
||||
},
|
||||
})
|
||||
beiFertig()
|
||||
} finally {
|
||||
setzeLaeuft(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Daten bearbeiten</span>
|
||||
<span className="ads-eyebrow" style={{ marginTop: "10px" }}>
|
||||
Hier stehen das technische Datenblatt und die Ausstattung. Modell, Kennzeichen,
|
||||
Ausführung, Erstzulassung und Fahrgestellnummer pflegst du unter Einstellungen → Fahrzeug
|
||||
einrichten.
|
||||
</span>
|
||||
</Tile>
|
||||
|
||||
{entwurfTechnik.map((gruppe, gi) => (
|
||||
<Tile key={gruppe.gruppe ?? gi}>
|
||||
<span className="ads-eyebrow">{gruppe.gruppe ?? "Weitere Angaben"}</span>
|
||||
{(gruppe.posten ?? []).map((posten, i) => (
|
||||
<Feld key={i} label={posten[0]} last={i === (gruppe.posten?.length ?? 0) - 1}>
|
||||
<span className="dm-selbst-zeile">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
placeholder="noch offen"
|
||||
value={posten[1]}
|
||||
onChange={(e) => {
|
||||
const wert = e.target.value
|
||||
setzeEntwurfTechnik((alt) =>
|
||||
alt.map((g, gj) =>
|
||||
gj !== gi
|
||||
? g
|
||||
: {
|
||||
...g,
|
||||
posten: (g.posten ?? []).map((p, pj) =>
|
||||
pj === i ? ([p[0], wert] as [string, string]) : p,
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="dm-zeile-loeschen"
|
||||
aria-label={`${posten[0]} entfernen`}
|
||||
onClick={() =>
|
||||
setzeEntwurfTechnik((alt) =>
|
||||
alt.map((g, gj) =>
|
||||
gj !== gi
|
||||
? g
|
||||
: { ...g, posten: (g.posten ?? []).filter((_, pj) => pj !== i) },
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</Feld>
|
||||
))}
|
||||
{(gruppe.posten?.length ?? 0) === 0 && (
|
||||
<span className="ads-eyebrow" style={{ marginTop: "8px" }}>
|
||||
Keine Angaben
|
||||
</span>
|
||||
)}
|
||||
<ActionButton
|
||||
onClick={() =>
|
||||
setzeEntwurfTechnik((alt) =>
|
||||
alt.map((g, gj) =>
|
||||
gj !== gi
|
||||
? g
|
||||
: { ...g, posten: [...(g.posten ?? []), ["Neue Angabe", ""] as [string, string]] },
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
+ Position hinzufügen
|
||||
</ActionButton>
|
||||
</Tile>
|
||||
))}
|
||||
|
||||
{entwurfAusstattung.map((gruppe, gi) => (
|
||||
<Tile key={gruppe.gruppe ?? gi}>
|
||||
<span className="ads-eyebrow">{gruppe.gruppe ?? "Weitere Ausstattung"}</span>
|
||||
{(gruppe.sonder ?? []).map((posten, i) => (
|
||||
<Feld key={i} label={`Position ${i + 1}`} last={i === (gruppe.sonder?.length ?? 0) - 1}>
|
||||
<span className="dm-selbst-zeile">
|
||||
<input
|
||||
className="dm-eingabe"
|
||||
value={posten}
|
||||
onChange={(e) => {
|
||||
const wert = e.target.value
|
||||
setzeEntwurfAusstattung((alt) =>
|
||||
alt.map((g, gj) =>
|
||||
gj !== gi
|
||||
? g
|
||||
: { ...g, sonder: (g.sonder ?? []).map((s, sj) => (sj === i ? wert : s)) },
|
||||
),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="dm-zeile-loeschen"
|
||||
aria-label={`Position ${i + 1} entfernen`}
|
||||
onClick={() =>
|
||||
setzeEntwurfAusstattung((alt) =>
|
||||
alt.map((g, gj) =>
|
||||
gj !== gi
|
||||
? g
|
||||
: { ...g, sonder: (g.sonder ?? []).filter((_, sj) => sj !== i) },
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</Feld>
|
||||
))}
|
||||
{(gruppe.sonder?.length ?? 0) === 0 && (
|
||||
<span className="ads-eyebrow" style={{ marginTop: "8px" }}>
|
||||
Keine Ausstattung hinterlegt
|
||||
</span>
|
||||
)}
|
||||
<ActionButton
|
||||
onClick={() =>
|
||||
setzeEntwurfAusstattung((alt) =>
|
||||
alt.map((g, gj) => (gj !== gi ? g : { ...g, sonder: [...(g.sonder ?? []), ""] })),
|
||||
)
|
||||
}
|
||||
>
|
||||
+ Position hinzufügen
|
||||
</ActionButton>
|
||||
</Tile>
|
||||
))}
|
||||
|
||||
<ActionButton variant="primary" onClick={() => void speichern()} disabled={laeuft}>
|
||||
{laeuft ? "Speichere …" : "Fertig"}
|
||||
</ActionButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
/**
|
||||
* 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.
|
||||
* Vier Sammelzeilen statt der früheren flachen Liste: Fahrzeug verriegelt /
|
||||
* Türen und Klappen geschlossen / Fenster und Dach geschlossen / Licht
|
||||
* ausgeschaltet - jede fasst mehrere Punkte aus `_sicherheitscheck` über
|
||||
* deren `gruppe`-Feld zusammen (siehe `gruppenErgebnis()` in bausteine.tsx).
|
||||
* Entscheidend bleibt: `ok === null` heißt „Sensor meldet nichts" und wird
|
||||
* als unbekannt dargestellt — nie als grün geraten. Nur "Türen und Klappen
|
||||
* geschlossen" führt zu einer Detailseite (TuerenKlappen.tsx) mit jeder Tür,
|
||||
* Motorhaube und dem Kofferraum einzeln.
|
||||
*/
|
||||
|
||||
import { StatusRow, Tile } from "@audi-dash/ui"
|
||||
import { Tile } from "@audi-dash/ui"
|
||||
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { datumZeit } from "../format"
|
||||
import { Leerzustand } from "./bausteine"
|
||||
import type { SeitenName } from "../navigation"
|
||||
import { gruppenErgebnis, Leerzustand, StatusKreis } from "./bausteine"
|
||||
import { Bild } from "./Bild"
|
||||
import { standortZustand } from "./Standort"
|
||||
|
||||
export function Fahrzeugstatus() {
|
||||
const { fahrzeug, statusStand } = useDaten()
|
||||
export function Fahrzeugstatus({ geheZu }: { geheZu: (name: SeitenName) => void }) {
|
||||
const { fahrzeug } = useDaten()
|
||||
if (!fahrzeug) return null
|
||||
|
||||
const punkte = fahrzeug.sicherheitscheck
|
||||
@@ -27,54 +33,100 @@ export function Fahrzeugstatus() {
|
||||
)
|
||||
}
|
||||
|
||||
const unbekannt = punkte.filter((p) => p.ok === null).length
|
||||
const offen = punkte.filter((p) => p.ok === false)
|
||||
const verriegelt = gruppenErgebnis(punkte.filter((p) => p.gruppe === "verriegelt"))
|
||||
const tuerenKlappen = gruppenErgebnis(punkte.filter((p) => p.gruppe === "tueren_klappen"))
|
||||
const fensterDach = gruppenErgebnis(punkte.filter((p) => p.gruppe === "fenster_dach"))
|
||||
const licht = gruppenErgebnis(punkte.filter((p) => p.gruppe === "licht"))
|
||||
const gesichert = fahrzeug.gesichert
|
||||
/* Dieselbe Regel wie auf der Übersicht: unterwegs ist der Wagen nicht
|
||||
abgestellt (Vorgabe des Eigentümers, 05.09.2026). Die vier Sammelzeilen
|
||||
darunter bleiben stehen und zeigen weiter, was die Tür-, Fenster- und
|
||||
Schlosssensoren melden — nur die Überschrift darf nicht „abgestellt"
|
||||
behaupten, während die Zündung an ist. */
|
||||
const faehrt = standortZustand(fahrzeug).faehrt
|
||||
const fehlerAnzahl =
|
||||
typeof fahrzeug.fehlerspeicher?.anzahl === "number" ? fahrzeug.fehlerspeicher.anzahl : null
|
||||
const fehlerOk = fehlerAnzahl === null ? null : fehlerAnzahl === 0
|
||||
const fehlerText =
|
||||
fehlerAnzahl === null
|
||||
? "Fehlerspeicher unbekannt"
|
||||
: fehlerAnzahl === 0
|
||||
? "Keine Fehlermeldungen"
|
||||
: fehlerAnzahl === 1
|
||||
? "1 Fehlermeldung"
|
||||
: `${fehlerAnzahl} Fehlermeldungen`
|
||||
const sammelText = faehrt
|
||||
? "Zündung an"
|
||||
: gesichert === null
|
||||
? "Zustand nicht vollständig bekannt"
|
||||
: gesichert
|
||||
? "Fahrzeug ist sicher abgestellt"
|
||||
: "Bitte prüfen"
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusRow
|
||||
status={fahrzeug.gesichert === true ? "ok" : fahrzeug.gesichert === false ? "bad" : "warn"}
|
||||
title={
|
||||
fahrzeug.gesichert === true
|
||||
? "Alles verschlossen"
|
||||
: fahrzeug.gesichert === false
|
||||
? `${offen.length} Stelle${offen.length === 1 ? "" : "n"} offen`
|
||||
: "Zustand nicht vollständig bekannt"
|
||||
}
|
||||
{...(statusStand ? { subtitle: `Stand ${datumZeit(statusStand)}` } : {})}
|
||||
/>
|
||||
{/* Draufsicht, wie im Panel (.szene.bleed.schmal, "carfix klein") - war
|
||||
hier komplett unportiert (Parität-Audit 2026-08-30). Seit dem
|
||||
02.09.2026 eingerückt statt randlos, siehe .dm-szene--schmal. */}
|
||||
<div className="dm-szene dm-szene--schmal">
|
||||
<Bild datei="draufsicht.webp" beschreibung="Draufsicht" name="Draufsicht" />
|
||||
</div>
|
||||
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Einzelprüfungen</span>
|
||||
<ul className="dm-pruefliste">
|
||||
{punkte.map((punkt) => (
|
||||
<li key={punkt.label} className="dm-pruefliste__zeile">
|
||||
<span
|
||||
className={
|
||||
punkt.ok === true
|
||||
? "dm-punkt dm-punkt--ok"
|
||||
: punkt.ok === false
|
||||
? "dm-punkt dm-punkt--offen"
|
||||
: "dm-punkt dm-punkt--unbekannt"
|
||||
}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="dm-pruefliste__label">{punkt.label}</span>
|
||||
<span className="dm-pruefliste__wert">
|
||||
{punkt.ok === true ? "zu" : punkt.ok === false ? "offen" : "unbekannt"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{/* Deckungsgleich mit der obersten Kachel in vSicherheit(): dasselbe
|
||||
Kreis-Häkchen-Symbol wie die vier Zeilen unten (nicht der generische
|
||||
StatusRow-Punkt), keine Zeitangabe - das Panel zeigt dort keine. */}
|
||||
<Tile className="dm-sicherheitssammel">
|
||||
{/* Während der Fahrt weder Haken noch Kreuz noch Fragezeichen, sondern
|
||||
derselbe Punkt wie im Standortmenü — der Zustand ist dann nicht
|
||||
„geprüft", sondern schlicht ein anderer. */}
|
||||
{faehrt ? <span className="ads-dot ads-dot--warn" /> : <StatusKreis ok={gesichert} />}
|
||||
<span>{sammelText}</span>
|
||||
</Tile>
|
||||
|
||||
{unbekannt > 0 && (
|
||||
<p className="dm-fussnote">
|
||||
{unbekannt === 1 ? "Eine Prüfung ist" : `${unbekannt} Prüfungen sind`} unbekannt, weil das
|
||||
Fahrzeug dazu nichts meldet. Solange das so ist, gilt die Gesamtaussage bewusst als
|
||||
unbekannt statt als sicher.
|
||||
</p>
|
||||
)}
|
||||
<Tile>
|
||||
<div className="dm-sicherheitsliste">
|
||||
<div className="dm-sicherheitszeile">
|
||||
<StatusKreis ok={verriegelt} />
|
||||
<span className="dm-sicherheitszeile__text">
|
||||
{verriegelt === null ? "Verriegelung unbekannt" : verriegelt ? "Fahrzeug verriegelt" : "Fahrzeug nicht verriegelt"}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" className="dm-sicherheitszeile" onClick={() => geheZu("tuerenklappen")}>
|
||||
<StatusKreis ok={tuerenKlappen} />
|
||||
<span className="dm-sicherheitszeile__text">
|
||||
{tuerenKlappen === null
|
||||
? "Türen/Klappen unbekannt"
|
||||
: tuerenKlappen
|
||||
? "Türen und Klappen geschlossen"
|
||||
: "Türen oder Klappen offen"}
|
||||
</span>
|
||||
<svg className="dm-sicherheitszeile__chev" viewBox="0 0 6 10" width="6" height="10" aria-hidden="true">
|
||||
<path d="M1 1l4 4-4 4" fill="none" stroke="currentColor" strokeWidth="1.4" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="dm-sicherheitszeile">
|
||||
<StatusKreis ok={fensterDach} />
|
||||
<span className="dm-sicherheitszeile__text">
|
||||
{fensterDach === null ? "Fenster/Dach unbekannt" : fensterDach ? "Fenster und Dach geschlossen" : "Fenster oder Dach offen"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="dm-sicherheitszeile">
|
||||
<StatusKreis ok={licht} />
|
||||
<span className="dm-sicherheitszeile__text">
|
||||
{licht === null ? "Licht unbekannt" : licht ? "Licht ausgeschaltet" : "Licht eingeschaltet"}
|
||||
</span>
|
||||
</div>
|
||||
{/* Der Fehlerspeicher des Fahrzeugs. Steht bewusst NICHT im
|
||||
Sicherheitscheck und geht damit auch nicht in „sicher
|
||||
abgestellt" ein — ein Eintrag sagt nichts darüber, ob der Wagen
|
||||
verschlossen dasteht (siehe _fehlerspeicher() im Backend).
|
||||
WELCHER Fehler vorliegt, meldet das Gerät nicht. */}
|
||||
<div className="dm-sicherheitszeile">
|
||||
<StatusKreis ok={fehlerOk} />
|
||||
<span className="dm-sicherheitszeile__text">{fehlerText}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Tile>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,22 +7,40 @@
|
||||
*/
|
||||
|
||||
import type { Verbindungszustand, WartenderAuftrag } from "../api"
|
||||
import type { Versionsstand } from "../daten/appVersion"
|
||||
import { SymbolWarnung } from "../symbole"
|
||||
|
||||
export function Hinweisleiste({
|
||||
verbindung,
|
||||
warteschlange,
|
||||
versionsstand = "unbekannt",
|
||||
}: {
|
||||
verbindung: Verbindungszustand
|
||||
warteschlange: readonly WartenderAuftrag[]
|
||||
/** Ob diese App noch dem Stand entspricht, den das Backend ausliefert.
|
||||
Siehe daten/appVersion.ts und VERSIONIERUNG.md. */
|
||||
versionsstand?: Versionsstand
|
||||
}) {
|
||||
const offline = verbindung !== "verbunden"
|
||||
const wartend = warteschlange.length
|
||||
// Derselbe Grundsatz wie beim Offline-Hinweis oben: keine stille Veraltung.
|
||||
// Nur hier veraltet nicht die Anzeige, sondern die App selbst — sie trägt
|
||||
// ihre Dateien fest gebündelt und merkt eine neuere Fassung sonst nie.
|
||||
//
|
||||
// "neuer" schlägt bewusst NICHT an: direkt nach einem Xcode-Lauf ist die
|
||||
// App der Instanz voraus, und daraus einen Handlungsbedarf zu machen
|
||||
// hiesse, den Nutzer zu einem Rückschritt zu drängen (siehe
|
||||
// daten/appVersion.ts). Nur bei einer nicht sortierbaren Abweichung wird
|
||||
// neutral gemeldet, statt eine Richtung zu behaupten - der Grundsatz
|
||||
// 'keine stille Veraltung' gilt auch dann.
|
||||
const veraltet = versionsstand === "aelter"
|
||||
const unklar = versionsstand === "abweichend"
|
||||
const versionshinweis = veraltet || unklar
|
||||
|
||||
// "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
|
||||
if (!offline && wartend === 0 && !versionshinweis) return null
|
||||
if (verbindung === "verbindet" && wartend === 0 && !versionshinweis) return null
|
||||
|
||||
const gescheitert = warteschlange.filter((a) => a.fehlversuche > 0).length
|
||||
|
||||
@@ -42,6 +60,9 @@ export function Hinweisleiste({
|
||||
: `${wartend} Änderungen warten auf Übertragung.`
|
||||
: null}
|
||||
{gescheitert > 0 ? ` ${gescheitert} davon mit Fehlversuchen.` : null}
|
||||
{versionshinweis && (offline || wartend > 0) ? " " : null}
|
||||
{veraltet ? "Diese App ist älter als der Server — bitte aktualisieren." : null}
|
||||
{unklar ? "App und Server melden verschiedene Fassungen." : null}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||