diff --git a/AGENTS.md b/AGENTS.md index 7ff0546..f287653 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,9 @@ # AGENTS.md — Project state, review findings, open items, and working rules -**Last updated: 2026-09-04** (Geraetezeit statt Ankunftszeit - die Wurzel hinter der 3-km-Fahrt: +**Last updated: 2026-09-04** (Tankstelle zweizeilig und antippbar, die Belegkarte sagt bei +fehlender Position die Wahrheit statt einen toten Knopf zu zeigen, der Regler bekommt einen +Speichern-Knopf, und die Share-Erweiterung trug eine andere Versionsnummer als die App - `2026.9.4.5`, +Abschnitt CC. Davor: Geraetezeit statt Ankunftszeit - die Wurzel hinter der 3-km-Fahrt: das Fahrtfenster stand in Geraetezeit, der Verlauf war nach Ankunftszeit sortiert. Dazu die neun Paritaetsbefunde und die verunreinigte Batteriehistorie, `2026.9.4.1`, Abschnitt CB. Davor: Der Regler stellt den Schlaf-Timeout des Dongles, flespi lesend und schreibend, die eigene Warteschlange wieder entfallen, `2026.9.3.10`, Abschnitt BZ. Davor: Zugaenge: die Dienste-Token aus App und Panel bedienbar, der Wert kommt nie @@ -9462,3 +9465,167 @@ resting voltage, the SOC, the SOH trend and the fallback shown in "Mein Audi / Z the same mechanism as `wert_in_naehe()` with its 300 s window for the outside temperature. Without a vehicle, nothing gets recorded. The 13 existing entries are the owner's data; removal is via the swipe gesture in the measurement list. + +## CC. Tankstelle zweizeilig und antippbar, die Belegkarte sagt die Wahrheit, der Regler bekommt einen Knopf (2026.9.4.5) + +Sieben Meldungen des Eigentuemers, dazu die beiden Dongle-Parameter aus dem Vortag. + +### Der Dongle steht jetzt auf 1003 = 60 und 1004 = AVL + +Am Geraet nachgesehen statt angenommen: `network_ping.timeout` stand auf **0** und +`ack_type.mode` auf **0 (TCP/IP)** — die Werte, die am 03.09. die tote TCP-Sitzung +unbemerkt liessen (Abschnitt CB). Beide ueber `PUT /gw/devices/{id}/settings/{name}` +mit `{"properties": …, "address": "connection"}` geschrieben, dieselbe Form wie +`schlaf_timeout_schreiben()`. flespi haelt sie als **`pending`** vor und stellt sie +zu, sobald sich das Geraet meldet; `current` steht bis dahin unveraendert auf 0/0. +`records_params` (1000 = 60 s, 1001 = 30 s, 1002 = Oldest) bleibt wie es war. + +### "Einzelbeleg/Map laedt die Position nicht" und "der Button funktioniert nicht" + +**Dieselbe Stelle, ein Befund.** Reproduziert an einem Beleg ohne aufloesbare +Anschrift: das Panel zeichnete dann einen beliebigen Kartenausschnitt und liess den +Zentrieren-Knopf fuer immer `disabled`; die App zeigte ihren Leerzustand — und +suchte gar nicht erst, weil `TankstellenKarte` ausschliesslich `station_address` +nachschlug. Von 16 Tankvorgaengen der Testinstanz tragen **vier** eine Anschrift; +das Panel faellt seit jeher auf den Namen zurueck (`f.station_address || +f.station_name`), die App nicht. + +Beide zeigen jetzt bei fehlender Position **keine Karte**, sondern die Auskunft — +und unterscheiden dabei "steht nicht im Beleg" von "findet niemand". Im Panel +entscheidet das `BELEG_KARTE`, ein Merker, den `initMap()` setzt, wenn der +Nachschlag erfolglos zurueckkommt; `go()` und das Speichern eines Tankvorgangs +raeumen ihn wieder ab, damit eine korrigierte Anschrift erneut gesucht wird. + +**Der Suchbegriff war der zweite Fallstrick, und er hat mich einmal erwischt.** +Die erste Fassung setzte Name UND Anschrift zusammen, mit der Begruendung "eine +Hausnummer ohne Betrieb trifft in Gewerbegebieten das Nachbargebaeude". Live +gemessen ist das genau falsch herum: `"Pascalstr. 8, 85057 Ingolstadt"` findet +Nominatim, `"A. Zrenner GmbH, Pascalstr. 8, 85057 Ingolstadt"` **nicht** — der +Betreibername steht in keiner Karte. Sichtbar wurde es sofort als "Keine Position" +bei einem Beleg, dessen Anschrift sich vorher aufloesen liess. + +Deshalb jetzt zwei getrennte Begriffe: + +* `tankstelleKandidaten()` — **erst die Anschrift, dann der Name**, nacheinander + versucht. Der Name allein ist wertvoll (`"Shell, SÖLDEN"` findet Nominatim), nur + eben nicht als Praefix. +* `tankstelleZiel()` — Name UND Anschrift, aber nur fuer den Kartendienst: Apple + Karten und Google Maps kennen Betriebsnamen und sind darin deutlich besser als + Nominatims Freitextsuche. + +### Die Tankstelle steht ueber die volle Breite, in zwei Zeilen, und fuehrt in die Karten + +Vorgabe des Eigentuemers: erste Zeile Tankstelle und Strasse, zweite Zeile der Ort, +Tippen oeffnet den Standard-Kartendienst. Vorher eine gewoehnliche Wertzeile mit +120px Wertespalte — "Hermann Mogler Mineraloelg. GmbH, Heidenheimerstr.6, 89551 +Koenigsbronn" stand dort vier- bis fuenfzeilig. + +Geteilt wird am **letzten** Komma (der Belegparser setzt `", "` +zusammen), damit ein Zusatz bei der Strasse bleibt; ohne Komma bleibt alles in der +ersten Zeile statt einen Ort zu erfinden. `kartendienstUrl()` waehlt nach Geraet: +`maps.apple.com` auf Apple-Geraeten, sonst `google.com/maps/dir`. Dieselbe Wahl +gilt jetzt auch fuer den Fahrzeugstandort und das Autohaus — drei Verweise, ein +Dienst. + +**Zwei CSS-Fallen dabei, beide erst am laufenden Panel sichtbar:** + +1. `.tankstellenzeile dt` (0,1,1) verliert gegen `.row dt` im **iOS-Overlay**, das + spaeter geladen wird — die Schriftgroesse blieb 16px. Die Regeln heissen deshalb + `.row.tankstellenzeile …` (0,2,1) und gewinnen unabhaengig von der Reihenfolge. +2. `.rows-fest dt { flex: 0 0 150px }` — in einer **Spalte** ist die Flex-Basis die + HOEHE. Der Titel "Tankstelle" war damit 150px hoch, die Zeile 214px. `flex: 0 0 + auto` in derselben Regel hebt es auf. Gemessen 82px danach. + +**Merkposten, zum dritten Mal in dieser Datei:** eine Regel, die im Overlay einen +Namensvetter hat, braucht den Wert des Overlays oder hoehere Spezifitaet — und eine +Flex-Angabe bedeutet in einer Spalte etwas anderes als in einer Zeile. + +### "Wozu der Zeilenumbruch bei SmartDeal-Ersparnis?" + +Kein Platzmangel, sondern ein Rundungsfehler, am Einzelbeleg bei 375px gemessen: +die dt-Spalte war **150,00px** breit, ihr eigener Inhalt **150,20px** — der Text +hinter dem Markenlogo rutschte in eine zweite Zeile, die Zeile wurde 44,8px statt +26,4px hoch. Zwei Zehntel Pixel. `.marke { white-space: nowrap }` in beiden +Codebasen, und die Wertespalte gibt in genau dieser Zeile ihre feste Mindestbreite +auf (`.rows-fest .row-marke dd { min-width: 0 }`); ohne das stuende sie um 10px +ueber. Live bestaetigt: 26,4px, eine Zeile. + +### Der Regler schreibt nicht mehr beim Loslassen + +Vorgabe des Eigentuemers, und sie ist richtig: derselbe Wert stellt den +Schlaf-Timeout des Dongles — eine Aenderung am Fahrzeug ueber zwei Netze, die +bewusst ausgeloest werden soll und nicht als Nebenwirkung einer Wischgeste. + +Apples Richtlinien lassen einen Regler sonst sofort wirken; die Ausnahme ist genau +dieser Fall, und das Muster dafuer ist der bestaetigende Knopf. Er erscheint **nur**, +wenn der Regler wirklich woanders steht (ein dauerhaft sichtbarer, meist +wirkungsloser Knopf waere Beiwerk), daneben "Verwerfen" als schlichter Text — +dieselbe Anordnung wie in der Speicherleiste der App am Seitenende. + +Panel: `PAUSENZEIT_ENTWURF` neben `CONFIG.pausenzeit`; der `input`-Zweig zieht nur +Fuellung und Anzeige nach (ein `render()` mitten in der Geste erzeugt den Regler neu +und der Daumen springt aus der Hand), der `change`-Zweig setzt den Entwurf und +zeichnet neu, geschrieben wird erst auf Knopfdruck. App: eigener +`pausenzeitEntwurf`-Zustand **neben** dem Seitenentwurf, und `speichern(zusatz)` +nimmt den Wert als Argument entgegen — `setState` wirkt erst beim naechsten +Durchlauf, der Seitenentwurf trueg im selben Rendern noch den alten. + +Live in beiden geprueft (Regler bewegt, Knopf erscheint, "Verwerfen" stellt 15 Min. +wieder her). **"Speichern" bewusst NICHT gedrueckt** — das schriebe einen neuen +Schlaf-Timeout an den echten Dongle. + +### Teilen-Erweiterung und Zwischenablage am iPhone + +Die ausgelieferte `App.ipa` vom 03.09. wurde entpackt und geprueft, statt zu raten. +**Fast alles ist in Ordnung:** die `.appex` traegt ein echtes arm64-Programm +(110.320 Bytes), App und Erweiterung haben beide `group.app.datametric360` in ihren +Entitlements, das URL-Schema `datametric360` steht im Info.plist, und +`BelegZwischenablagePlugin` ist im App-Programm enthalten. + +**Ein Befund, und er erklaert genau "die Erweiterung erscheint nicht":** die +Versionsnummern stimmen nicht ueberein — App `2026.9.3.3`, Erweiterung `1.0`. +Apples App Extension Programming Guide verlangt Gleichheit, und iOS registriert +eine Erweiterung mit abweichender Nummer nicht. `ios-signieren.sh` schrieb die +Version bis dahin nur in `ios/App/App/Info.plist`; `native/teilen/Info.plist` +traegt die 1.0 fest. Jetzt bekommen **beide** Ziele dieselbe +`CFBundleShortVersionString` und `CFBundleVersion`. + +Dazu, aus der Lehre von Abschnitt AK (von fuenf Fehlern in der nativen Huelle haben +vier einen erfolgreichen Archivlauf erzeugt): das Skript liest die **fertige .ipa** +gegen — Programm in der `.appex` vorhanden, Versionen gleich, App-Gruppe auf beiden +Zielen — und warnt, statt es dem naechsten Fehlerbericht zu ueberlassen. + +Fuer die Zwischenablage kommt ein dritter Weg dazu: `UIPasteboard.itemProviders`. +Manche Apps legen eine Datei nur als **Zusage** ab — dann gibt +`data(forPasteboardType:)` nil zurueck und `urls` bleibt leer, nur der Anbieter +kennt sie. Und `zwischenablage.ts` fragt jetzt `Capacitor.isPluginAvailable()`: +fehlt der native Teil (eine App, die nur ueber OTA gewachsen ist), sagt die Meldung +das, statt "Kein Zugriff auf die Zwischenablage" — die schickt in die falsche +Richtung. + +**Was das nicht kann:** ohne Mac laesst sich keine dieser Aenderungen ausprobieren. +Der native Teil kommt ausschliesslich ueber einen Xcode-Lauf aufs Geraet, nie ueber +das OTA-Buendel. + +**Bewusst nicht angefasst:** die `NSExtensionActivationRule`. Sie ist heute +grosszuegig (jede Datei, jeder Anhang) und laesst die App im Teilen-Blatt +erscheinen; ein strenges Praedikat waere sauberer, koennte sie aber ungeprueft ganz +verschwinden lassen. + +### Fahrt `t-66c4209276e7` bleibt bei 3,0 km + +Ausdrueckliche Ansage des Eigentuemers ("Fahrt nicht neu rechnen"). Das Screening +fuellt grundsaetzlich nur leere Felder — der gespeicherte Wert bleibt damit von +selbst stehen. + +### Testinstanz: was von dieser Sitzung dort liegenbleibt + +* **`f-8852746248fa` traegt jetzt `discount: 12.9`** — von Hand gesetzt, um die + SmartDeal-Zeile ueberhaupt sichtbar zu machen (alle Testbelege hatten `null`). + Sinnvoll fuer die naechste Sichtpruefung, aber es ist eine Aenderung an den Daten + des Containers. +* **`/config/www/dmapp/`** haelt das gebaute Webbuendel; unter + `http://localhost:18123/local/dmapp/index.html` laeuft die App **gleichursprunglich** + zur Testinstanz. Das ist der Weg, die App live zu pruefen, ohne an CORS zu + scheitern: `cors_allowed_origins` in der `configuration.yaml` blieb wirkungslos + (Preflight weiterhin 403), der eingetragene Block ist deshalb wieder entfernt. diff --git a/companion-app/native/BelegZwischenablage.swift b/companion-app/native/BelegZwischenablage.swift index ab79e03..394f419 100644 --- a/companion-app/native/BelegZwischenablage.swift +++ b/companion-app/native/BelegZwischenablage.swift @@ -18,12 +18,19 @@ // 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. // -// ZWEI FORMEN, IN DENEN EIN PDF IN DER ZWISCHENABLAGE LIEGT +// 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 @@ -69,6 +76,24 @@ public class BelegZwischenablagePlugin: CAPPlugin, CAPBridgedPlugin { } } - call.resolve([:]) + // 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", + ]) + } } } diff --git a/companion-app/scripts/ios-signieren.sh b/companion-app/scripts/ios-signieren.sh index 12fec5d..9088073 100755 --- a/companion-app/scripts/ios-signieren.sh +++ b/companion-app/scripts/ios-signieren.sh @@ -172,7 +172,25 @@ VERSION="$(node -p "require('../custom_components/audi_dashboard/manifest.json') if ! /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$PLIST" 2>/dev/null; then /usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string $VERSION" "$PLIST" fi -echo " $VERSION" +BUILDNR="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$PLIST" 2>/dev/null || echo 1)" + +# 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. # @@ -252,6 +270,37 @@ xcodebuild -exportArchive \ mkdir -p "$APP/auslieferung" cp "$AUSGABE/App.ipa" "$APP/auslieferung/App.ipa" +# 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 "$APP/auslieferung/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 + 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 +fi +rm -rf "$PRUEF" + # Auslieferung ueber die Luft gleich mitschreiben. # # manifest.plist und die Installationsseite hingen bisher an einem zweiten, diff --git a/companion-app/src/daten/tankstelle.test.ts b/companion-app/src/daten/tankstelle.test.ts new file mode 100644 index 0000000..060499b --- /dev/null +++ b/companion-app/src/daten/tankstelle.test.ts @@ -0,0 +1,151 @@ +/** + * 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 { + adresseTeilen, + kartendienstUrl, + tankstelleKandidaten, + tankstelleSuchtext, + tankstelleZeilen, + tankstelleZiel, +} from "./tankstelle" + +function alsGeraet(kennung: string) { + vi.stubGlobal("navigator", { userAgent: kennung, platform: "" }) +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe("adresseTeilen", () => { + it("trennt Straße und Ort am Komma", () => { + expect(adresseTeilen("Pascalstr. 8, 85057 Ingolstadt")).toEqual({ + strasse: "Pascalstr. 8", + ort: "85057 Ingolstadt", + }) + }) + + it("trennt am LETZTEN Komma - Zusätze bleiben bei der Straße", () => { + expect(adresseTeilen("Nürnberger Str.74, Gewerbepark, 91522 Ansbach")).toEqual({ + strasse: "Nürnberger Str.74, Gewerbepark", + ort: "91522 Ansbach", + }) + }) + + it("erfindet ohne Komma keinen Ort", () => { + expect(adresseTeilen("Heidenheimerstr.6")).toEqual({ strasse: "Heidenheimerstr.6", ort: "" }) + }) + + it("verträgt leer und null", () => { + expect(adresseTeilen(null)).toEqual({ strasse: "", ort: "" }) + expect(adresseTeilen(" ")).toEqual({ strasse: "", ort: "" }) + }) +}) + +describe("tankstelleZeilen", () => { + it("setzt Name und Straße in die erste Zeile, den Ort in die zweite", () => { + expect(tankstelleZeilen("A. Zrenner GmbH", "Pascalstr. 8, 85057 Ingolstadt")).toEqual({ + oben: "A. Zrenner GmbH, Pascalstr. 8", + unten: "85057 Ingolstadt", + }) + }) + + it("kommt ohne Anschrift aus", () => { + 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", () => { + expect(tankstelleKandidaten("Shell, SÖLDEN", null)).toEqual(["Shell, SÖLDEN"]) + expect(tankstelleKandidaten(null, null)).toEqual([]) + }) +}) + +describe("tankstelleZiel", () => { + it("nimmt fuer den Kartendienst Name und Anschrift zusammen", () => { + expect(tankstelleZiel("A. Zrenner GmbH", "Pascalstr. 8, 85057 Ingolstadt")).toBe( + "A. Zrenner GmbH, 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("A. Zrenner GmbH", "Pascalstr. 8, 85057 Ingolstadt")).toBe( + "https://maps.apple.com/?daddr=A.%20Zrenner%20GmbH%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() + }) +}) diff --git a/companion-app/src/daten/tankstelle.ts b/companion-app/src/daten/tankstelle.ts new file mode 100644 index 0000000..8e83f3d --- /dev/null +++ b/companion-app/src/daten/tankstelle.ts @@ -0,0 +1,137 @@ +/** + * 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 `", "` 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 +} + +/** `"Pascalstr. 8, 85057 Ingolstadt"` → Straße und Ort. */ +export function adresseTeilen(adresse: string | null | undefined): { + strasse: string + ort: string +} { + const roh = (adresse ?? "").trim() + if (!roh) return { strasse: "", ort: "" } + const komma = roh.lastIndexOf(",") + if (komma < 0) return { strasse: roh, ort: "" } + return { strasse: roh.slice(0, komma).trim(), ort: roh.slice(komma + 1).trim() } +} + +/** + * Die beiden Zeilen der Tankstellen-Angabe. `oben` ist nie leer: steht weder + * Name noch Anschrift im Beleg, heißt es dort "unbekannt" - dieselbe Auskunft, + * die die Zeile vorher gab. + */ +export function tankstelleZeilen( + name: string | null | undefined, + adresse: string | null | undefined, +): { oben: string; unten: string } { + const { strasse, ort } = adresseTeilen(adresse) + const oben = [(name ?? "").trim(), strasse].filter(Boolean).join(", ") + return { oben: oben || "unbekannt", unten: ort } +} + +/** + * 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. + */ +export function tankstelleKandidaten( + name: string | null | undefined, + adresse: string | null | undefined, +): string[] { + return [(adresse ?? "").trim(), (name ?? "").trim()].filter(Boolean) +} + +/** 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. Hier gehören Name UND Anschrift zusammen: + * Apple Karten und Google Maps kennen Betriebsnamen und treffen damit die + * Zapfsäule statt des Nachbargebäudes - beide sind darin deutlich besser als + * Nominatims Freitextsuche. + */ +export function tankstelleZiel( + name: string | null | undefined, + adresse: string | null | undefined, +): string { + return [(name ?? "").trim(), (adresse ?? "").trim()].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) +} + +/** + * 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, +): string | null { + const beschriftung = (name ?? "").trim() + const ziel = pos ? `${pos.lat},${pos.lon}` : tankstelleZiel(name, adresse) + if (!ziel) return null + if (apfelgeraet()) { + const q = pos && beschriftung ? `&q=${encodeURIComponent(beschriftung)}` : "" + return `https://maps.apple.com/?daddr=${encodeURIComponent(ziel)}${q}` + } + return `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) +} diff --git a/companion-app/src/daten/zwischenablage.ts b/companion-app/src/daten/zwischenablage.ts index fe49bbc..207f2d0 100644 --- a/companion-app/src/daten/zwischenablage.ts +++ b/companion-app/src/daten/zwischenablage.ts @@ -67,12 +67,23 @@ function base64ZuDatei(base64: string, name: string): File { */ export async function pdfAusZwischenablage(): Promise { if (Capacitor.isNativePlatform()) { + /* Der native Teil steckt in der .ipa, nicht im OTA-Bündel: eine App, die + nur über die Aktualisierung in der App gewachsen ist, hat ihn nicht. + Genau das war bis zum 04.09.2026 nicht zu erkennen - der Fehlschlag kam + mit demselben Text wie eine abgelehnte Rückfrage ("Kein Zugriff"), und + der schickt in die falsche Richtung. `isPluginAvailable` fragt die + Brücke, ob die Klasse im Programm ueberhaupt vorhanden ist. */ + if (!Capacitor.isPluginAvailable("BelegZwischenablage")) { + throw new ZwischenablageFehler( + "Diese App-Fassung enthält den nativen Teil noch nicht - er kommt nur mit " + + 'einer neu installierten App, nicht über die Aktualisierung. Bis dahin "Datei auswählen".', + ) + } let ergebnis: { base64?: string; name?: string } try { ergebnis = await Nativ.pdfHolen() } catch { - // Abgelehnte Einfügen-Rückfrage oder ein Gerät ohne das Plugin (eine - // .ipa von vor dem 02.09.2026). + // Abgelehnte Einfügen-Rückfrage des Systems. throw new ZwischenablageFehler( 'Kein Zugriff auf die Zwischenablage - bitte "Datei auswählen".', ) diff --git a/companion-app/src/screens/Einstellungen.tsx b/companion-app/src/screens/Einstellungen.tsx index 004ae24..0e1799c 100644 --- a/companion-app/src/screens/Einstellungen.tsx +++ b/companion-app/src/screens/Einstellungen.tsx @@ -58,6 +58,12 @@ export function Einstellungen({ // eigener Entwurf, der beim Speichern mitgeschrieben wird. const [erstzulassung, setzeErstzulassung] = useState(null) const [entwurf, setzeEntwurf] = useState(null) + /* Der Regler "Fahrt beenden" fuehrt einen EIGENEN Entwurf, nicht den der + Seite. Grund: sein Wert stellt zugleich den Schlaf-Timeout des Dongles - + eine Aenderung am Fahrzeug, die ueber zwei Netze geht. Sie soll bewusst + ausgeloest werden, nicht als Nebenwirkung des Loslassens (Vorgabe des + Eigentuemers, 04.09.2026). `null` heisst: nichts angefasst. */ + const [pausenzeitEntwurf, setzePausenzeitEntwurf] = useState(null) const [laeuft, setzeLaeuft] = useState(false) const [gespeichert, setzeGespeichert] = useState(false) const [importOffen, setzeImportOffen] = useState(false) @@ -187,6 +193,8 @@ export function Einstellungen({ if (!einstellungen || !fahrzeug) return null const werte = entwurf ?? einstellungen + const pausenzeit = pausenzeitEntwurf ?? werte.pausenzeitMin + const pausenzeitGeaendert = pausenzeitEntwurf !== null && pausenzeitEntwurf !== werte.pausenzeitMin const erstzulassungWert = erstzulassung ?? fahrzeug.erstzulassung // Bei Winterraedern zeigt die Seitenansicht das Winterfoto - wie bildInfo() // im Panel, das hier dieselbe Vorschau liefert. @@ -209,17 +217,22 @@ export function Einstellungen({ const aendern = (teil: Partial) => setzeEntwurf({ ...werte, ...teil }) - const speichern = async () => { - if (!entwurf) return + /* `zusatz` kommt vom Regler der Kachel "Fahrt beenden": der fuehrt einen + eigenen Entwurf und uebergibt ihn erst beim Druck auf Speichern. Ohne den + Umweg wuerde `entwurf` im selben Rendern noch den alten Wert tragen - + setState wirkt erst beim naechsten Durchlauf. */ + const speichern = async (zusatz?: Partial) => { + if (!entwurf && !zusatz) return setzeLaeuft(true) setzeGespeichert(false) try { await profilSpeichern({ - einstellungen: entwurf, + einstellungen: { ...werte, ...(zusatz ?? {}) }, ...(erstzulassung !== null ? { fahrzeug: { ...fahrzeug, erstzulassung } } : {}), }) setzeEntwurf(null) setzeErstzulassung(null) + setzePausenzeitEntwurf(null) setzeGespeichert(true) } finally { setzeLaeuft(false) @@ -462,11 +475,33 @@ export function Einstellungen({ min={1} max={60} schritt={1} - wert={werte.pausenzeitMin} - onChange={(min) => aendern({ pausenzeitMin: min })} - anzeige={werte.pausenzeitMin + " Min."} + wert={pausenzeit} + onChange={setzePausenzeitEntwurf} + anzeige={pausenzeit + " Min."} aria-label="Minuten bis zum automatischen Fahrtende" /> + {/* Erscheint erst, wenn der Regler wirklich woanders steht - ein + dauerhaft sichtbarer, meist wirkungsloser Knopf waere Beiwerk. Die + Anordnung folgt der Speicherleiste am Seitenende: bestaetigende + Aktion als voller Knopf, der Ausweg daneben als schlichter Text. */} + {pausenzeitGeaendert && ( +
+ void speichern({ pausenzeitMin: pausenzeitEntwurf as number })} + disabled={laeuft} + > + {laeuft ? "Speichere …" : "Speichern"} + + +
+ )} {/* Fusszeile statt Info-Knopf: Apples eigenes Muster fuer erklaerenden Text unter einer Einstellung ist der Footer der Gruppe. */} diff --git a/companion-app/src/screens/Service.tsx b/companion-app/src/screens/Service.tsx index 1c050c5..cf0c125 100644 --- a/companion-app/src/screens/Service.tsx +++ b/companion-app/src/screens/Service.tsx @@ -12,6 +12,7 @@ import { useRef, useState } from "react" import { ActionButton, Feld, Fig, Tile } from "@audi-dash/ui" +import { kartendienstUrl } from "../daten/tankstelle" import { useDaten } from "../daten/DatenKontext" import { inspektionPrognose, @@ -213,7 +214,7 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) => className="ads-action dm-waehlknopf" target="_blank" rel="noopener" - href={`https://maps.google.com/?q=${encodeURIComponent(autohaus.adresse ?? "")}`} + href={kartendienstUrl(autohaus.name, autohaus.adresse) ?? undefined} > In Karten öffnen diff --git a/companion-app/src/screens/Standort.tsx b/companion-app/src/screens/Standort.tsx index faea3f6..d43daf6 100644 --- a/companion-app/src/screens/Standort.tsx +++ b/companion-app/src/screens/Standort.tsx @@ -12,6 +12,7 @@ import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } f import { useDaten } from "../daten/DatenKontext" import { koordinatenAufloesen } from "../daten/geokodierung" +import { kartendienstUrlZuPunkt } from "../daten/tankstelle" import { de } from "../format" import type { Fahrzeug } from "../daten/profilAdapter" import { SymbolTanken } from "../symbole" @@ -450,9 +451,9 @@ export function Standort() { const koordinaten = fahrzeugPos ? `${de(fahrzeugPos.lat, 4)}, ${de(fahrzeugPos.lon, 4)}` : "" const adresseAnzeige = !bekannt ? "Kein GPS-Signal vom Fahrzeug" : (adresse ?? koordinaten) const zustand = standortZustand(fahrzeug) - const kartenlink = fahrzeugPos - ? `https://www.google.com/maps/dir/?api=1&destination=${fahrzeugPos.lat},${fahrzeugPos.lon}` - : null + // Kartendienst des Geraets statt fest Google Maps: auf dem iPhone ist das + // Karten. Dieselbe Wahl wie beim Tankstellen-Verweis (daten/tankstelle.ts). + const kartenlink = kartendienstUrlZuPunkt(fahrzeugPos) const teilen = () => { if (!kartenlink) return diff --git a/companion-app/src/screens/TankDetail.tsx b/companion-app/src/screens/TankDetail.tsx index 50bfec7..e668ee7 100644 --- a/companion-app/src/screens/TankDetail.tsx +++ b/companion-app/src/screens/TankDetail.tsx @@ -14,6 +14,7 @@ import { ActionButton, Feld, Fig, Tile } from "@audi-dash/ui" import type { Tankvorgang } from "../api" import { ENTITAETEN } from "../api" import { useDaten } from "../daten/DatenKontext" +import { kartendienstUrl, tankstelleZeilen } from "../daten/tankstelle" import { datum, de, deOderStrich, eur, uhrzeit } from "../format" import type { SeitenName } from "../navigation" import { KRAFTSTOFFSORTEN, Leerzustand, Wertzeile, Werteliste, bestaetigen } from "./bausteine" @@ -122,11 +123,7 @@ export function TankDetail({ id, geheZu }: { id: string | undefined; geheZu: (na wert={`${eur(tank.discount)} €`} /> )} - + } /> @@ -141,6 +138,60 @@ export function TankDetail({ id, geheZu }: { id: string | undefined; geheZu: (na ) } +/** + * Tankstelle des Belegs: eigene Zeile über die volle Kachelbreite statt der + * engen Wertespalte, zweizeilig (Tankstelle und Straße / Ort) und antippbar - + * der Tipp führt in den Kartendienst des Geräts. Deckungsgleich mit + * `.tankstellenzeile` im Panel; die Aufteilung stammt vom Eigentümer + * (04.09.2026). + * + * Ohne Ziel bleibt es eine gewöhnliche Zeile: ein Verweis, der nirgendwohin + * führt, wäre schlechter als keiner. + */ +function Tankstellenzeile({ + name, + adresse, +}: { + name: string | null | undefined + adresse: string | null | undefined +}) { + const { oben, unten } = tankstelleZeilen(name, adresse) + const url = kartendienstUrl(name, adresse) + const inhalt = ( + <> + + {oben} + {unten && {unten}} + + {url && ( + + )} + + ) + return ( +
+
Tankstelle
+
+ {url ? ( + + {inhalt} + + ) : ( + {inhalt} + )} +
+
+ ) +} + /** "YYYY-MM-DDTHH:mm" in Ortszeit für ein . */ function alsDatetimeLocal(iso: string): string { const d = new Date(iso) diff --git a/companion-app/src/screens/TankstellenKarte.tsx b/companion-app/src/screens/TankstellenKarte.tsx index 080fc26..f36c435 100644 --- a/companion-app/src/screens/TankstellenKarte.tsx +++ b/companion-app/src/screens/TankstellenKarte.tsx @@ -3,15 +3,29 @@ * * Die Koordinaten stehen nicht im Datensatz — der Beleg trägt nur eine * Anschrift, und das Backend füllt `station_lat`/`station_lon` nie. Sie werden - * deshalb aus der Adresse aufgelöst (`adresseAufloesen()`), genau wie im - * Panel. Ohne Adresse oder ohne Treffer bleibt der ehrliche Leerzustand - * stehen, statt irgendeinen Kartenausschnitt zu zeigen. + * deshalb aus der Anschrift aufgelöst (`adresseAufloesen()`), genau wie im + * Panel. + * + * GESUCHT WIRD AUCH NACH DEM NAMEN + * -------------------------------- + * Bis zum 04.09.2026 wurde hier ausschliesslich `station_address` nachgeschlagen. + * Nur trägt die die Mehrzahl der Belege gar nicht: von 16 Tankvorgängen der + * Testinstanz haben vier eine Anschrift, alle übrigen bestenfalls einen Namen. + * Für die blieb die Karte dauerhaft leer ("Einzelbeleg/Map lädt die Position + * der Tankstelle nicht", Eigentümer 04.09.2026) - während das Panel längst + * `f.station_address || f.station_name` nachschlug. Hier steht jetzt dasselbe, + * über `tankstelleSuchtext()`: Anschrift bevorzugt, sonst der Name. + * + * Ohne beides bleibt der ehrliche Leerzustand stehen, statt irgendeinen + * Kartenausschnitt zu zeigen - und er sagt jetzt auch, WAS fehlt: eine + * Anschrift, die es nicht gibt, ist etwas anderes als eine, die niemand findet. */ import { useEffect, useState } from "react" import { adresseAufloesen } from "../daten/geokodierung" import type { Koordinate } from "../daten/geokodierung" +import { tankstelleKandidaten } from "../daten/tankstelle" import { Leerzustand } from "./bausteine" import { Karte } from "./Karte" @@ -24,19 +38,33 @@ export function TankstellenKarte({ }) { const [position, setzePosition] = useState(null) const [geprueft, setzeGeprueft] = useState(false) + // Als eine Zeichenkette, damit der Effekt nicht bei jedem Rendern neu läuft - + // ein frisches Array wäre bei jedem Durchlauf ein anderer Wert. + const suche = tankstelleKandidaten(name, adresse).join("\n") useEffect(() => { let abgebrochen = false setzeGeprueft(false) - void adresseAufloesen(adresse).then((treffer) => { - if (abgebrochen) return - setzePosition(treffer) - setzeGeprueft(true) - }) + const versuchen = async () => { + for (const begriff of suche ? suche.split("\n") : []) { + const treffer = await adresseAufloesen(begriff) + if (abgebrochen) return + if (treffer) { + setzePosition(treffer) + setzeGeprueft(true) + return + } + } + if (!abgebrochen) { + setzePosition(null) + setzeGeprueft(true) + } + } + void versuchen() return () => { abgebrochen = true } - }, [adresse]) + }, [suche]) // Wie im Panel (tankstelleAufKarteZeigen): die CI-Nadel mit Name und // Anschrift im Popup, dazu der Knopf, der wieder darauf zentriert. @@ -45,11 +73,15 @@ export function TankstellenKarte({ // Solange die Auflösung läuft: eine leere Kartenfläche, wie im Panel (die // Karte steht dort schon, die Nadel kommt nach). Bewusst nicht die // Ladeanzeige der Seite - die meldet „der Bildschirm ist noch nicht da". - if (!geprueft) return
+ if (!geprueft && suche) return
return ( ) } diff --git a/companion-app/src/stile/screens.css b/companion-app/src/stile/screens.css index c16b73c..7628ee7 100644 --- a/companion-app/src/stile/screens.css +++ b/companion-app/src/stile/screens.css @@ -897,6 +897,55 @@ button.dm-serviceblock:active { text-align: right; } +/* Tankstelle im Einzelbeleg (Vorgabe des Eigentuemers, 04.09.2026). + + Vorher eine gewoehnliche Wertzeile: Name rechts in der Wertespalte, die + ganze Anschrift als Kleintext darunter. Die Spalte ist rund 120px breit - + "Hermann Mogler Mineraloelg. GmbH, Heidenheimerstr.6, 89551 Koenigsbronn" + stand dort vier- bis fuenfzeilig. Hier bekommt sie die volle Kachelbreite + unter der Beschriftung, zwei Zeilen (Tankstelle und Strasse / Ort), und + fuehrt beim Tippen in den Kartendienst - deshalb der Chevron rechts. + + Deckungsgleich mit .tankstellenzeile im Panel. */ +.dm-tankstellenzeile { + display: flex; + flex-direction: column; + gap: 4px; + padding: 11px 0; + border-bottom: 0; +} +.dm-tankstellenzeile dt { + font-size: 13px; + color: var(--fg2); +} +.dm-tankstellenzeile dd { + margin: 0; +} +.dm-tankstellenzeile__inhalt { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + color: inherit; + text-decoration: none; +} +a.dm-tankstellenzeile__inhalt:active { + opacity: 0.6; +} +.dm-tankstellenzeile__ort { + display: block; + font-size: 16px; + color: var(--fg); + line-height: 1.35; + min-width: 0; +} +.dm-tankstellenzeile__ort small { + display: block; + margin-top: 2px; + font-size: 13px; + color: var(--fg3); +} + .dm-abschnitt { display: flex; align-items: baseline; @@ -1531,6 +1580,20 @@ button.dm-listenzeile { border-top: 1px solid var(--line); } +/* Dieselbe Leiste innerhalb einer Kachel (Regler "Fahrt beenden"): nicht + klebend, kein eigener Untergrund, keine Trennlinie - sie gehoert zur Kachel + und nicht an den Bildschirmrand. */ +.dm-speicherleiste--kachel { + position: static; + margin-top: var(--sp-3); + padding: 0; + background: none; + border-top: 0; +} +.dm-speicherleiste--kachel > :first-child { + flex: 1 1 auto; +} + .dm-szene__zusatz { min-width: 0; } @@ -1913,10 +1976,18 @@ button.dm-listenzeile { /* `.marke` im Panel: Bildmarke und Text nebeneinander, z. B. vor „SmartDeal-Ersparnis" auf dem Einzelbeleg. */ +/* white-space:nowrap: ohne die brach "SmartDeal-Ersparnis" neben dem + Markenlogo um. Der Grund ist kein Platzmangel, sondern ein Rundungsfehler - + am Panel gemessen (04.09.2026): die Spalte war 150,00px breit, ihr eigener + Inhalt 150,20px, also brach der Text nach dem Logo in eine zweite Zeile und + die Zeile wurde 44,8px statt 26,4px hoch. Die Zeile hat reichlich Platz, sie + darf ihn nur nicht abgeschnitten bekommen. (Im Panel kommt dazu, dass die + Wertespalte dort eine feste Mindestbreite hat - siehe .row-marke.) */ .dm-marke { display: inline-flex; align-items: center; gap: 6px; + white-space: nowrap; } .dm-marke img { diff --git a/custom_components/audi_dashboard/frontend/app/bundle.json b/custom_components/audi_dashboard/frontend/app/bundle.json index 060fcba..377e27e 100644 --- a/custom_components/audi_dashboard/frontend/app/bundle.json +++ b/custom_components/audi_dashboard/frontend/app/bundle.json @@ -1 +1 @@ -{"version":"2026.9.4.1","sha256":"473807fbe06bc94882a3b53fa76d167a64a15d9470c0a6f993c7042a8bf009b6","bytes":270085,"gebaut":"2026-09-04T00:09:13Z"} \ No newline at end of file +{"version":"2026.9.4.5","sha256":"ffea00a94ac842d8b6b233ebd91a1a97252c4b7b6a952c336ca7a6218eceef1e","bytes":270888,"gebaut":"2026-09-04T07:57:27Z"} \ No newline at end of file diff --git a/custom_components/audi_dashboard/frontend/app/bundle.zip b/custom_components/audi_dashboard/frontend/app/bundle.zip index 793d380..8855430 100644 Binary files a/custom_components/audi_dashboard/frontend/app/bundle.zip and b/custom_components/audi_dashboard/frontend/app/bundle.zip differ diff --git a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js index d37c469..639cae4 100644 --- a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js +++ b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js @@ -644,6 +644,11 @@ function ics(titel, datum, ort, notiz) { let MAP = null, TILE = null, LEAFLET_LADEND = null; // Zielpunkt des Zentrieren-Knopfs im Einzelbeleg (siehe mapBoxMitZentrieren). let BELEG_STATION_POS = null; +/* Merker, ob zu EINEM Beleg überhaupt eine Position zu finden war. Der Abruf + ist asynchron, die Ansicht aber schon gezeichnet - ohne diesen Merker + stünde bei einem erfolglosen Nachschlag dauerhaft eine Karte ohne Nadel da + (samt Knopf, der nie scharf wird). `{ id, zustand: "keine" }`, sonst null. */ +let BELEG_KARTE = null; /* Bewusst themeunabhaengig hell: Kartendienste (Google Maps & Co.) zeigen die Standard-Strassenkarte immer im hellen Stil, weil Strassen, Beschriftungen und Wegfarben darauf lesbar bleiben. Die frueher bei Nacht eingeblendeten @@ -843,18 +848,29 @@ async function initMap() { } else if (route.name === "fill") { BELEG_STATION_POS = null; // gehört zum vorherigen Beleg, sonst zentriert der Knopf falsch const f = FILLS.find((x) => x.tank_id === route.id); + const suche = f ? tankstelleSuchtext(f.station_name, f.station_address) : ""; if (f && f.station_lat != null) { tankstelleAufKarteZeigen(MAP, { lat: f.station_lat, lon: f.station_lon }, f); - } else if (f && (f.station_address || f.station_name)) { - /* Ohne Koordinaten die Anschrift vom Beleg nachschlagen. Bis die Antwort - da ist, bleibt der neutrale Ausschnitt stehen - und die Karte könnte - inzwischen zu einer anderen Ansicht gehören, deshalb vor dem Zeichnen - prüfen, ob wir noch auf demselben Beleg sind. */ + } else if (suche) { + /* Ohne Koordinaten die Anschrift vom Beleg nachschlagen - und wenn der + Beleg keine trägt, den Namen (dieselbe Reihenfolge wie in der App, + tankstelleSuchtext). Bis die Antwort da ist, bleibt der neutrale + Ausschnitt stehen - und die Karte könnte inzwischen zu einer anderen + Ansicht gehören, deshalb vor dem Zeichnen prüfen, ob wir noch auf + demselben Beleg sind. */ MAP.setView(kartenMittelpunkt(), 11); const karte = MAP, belegId = route.id; - adresseAufloesen(f.station_address || f.station_name).then((pos) => { - if (!pos || MAP !== karte || route.name !== "fill" || route.id !== belegId) return; - tankstelleAufKarteZeigen(karte, pos, f); + tankstellePosition(f.station_name, f.station_address).then((pos) => { + if (MAP !== karte || route.name !== "fill" || route.id !== belegId) return; + if (pos) return tankstelleAufKarteZeigen(karte, pos, f); + /* Kein Treffer: statt eines beliebigen Ausschnitts mit totem + Zentrieren-Knopf die ehrliche Auskunft. Genau die beiden Befunde + des Eigentümers vom 04.09.2026 - "Map lädt die Position der + Tankstelle nicht" und "der Button auf der Map funktioniert nicht" + waren dieselbe Stelle: eine Karte ohne Nadel und ein Knopf, der + nie scharf wurde. */ + BELEG_KARTE = { id: belegId, zustand: "keine" }; + render(); }); } else { MAP.setView(kartenMittelpunkt(), 11); @@ -1210,6 +1226,81 @@ async function adresseAufloesen(adresse) { return treffer; } +/* ------------------------------------------------------- Tankstelle + Anzeige in zwei Zeilen und der Weg in den Kartendienst des Geraets. + Wortgleich mit companion-app/src/daten/tankstelle.ts - dort steht die + ausfuehrliche Begruendung. + + Kurz: die Anschrift kommt vom Belegparser als ", "; + geteilt wird am LETZTEN Komma, damit ein Zusatz bei der Strasse bleibt. + Erste Zeile Tankstelle und Strasse, zweite Zeile der Ort (Vorgabe des + Eigentuemers, 04.09.2026). Ohne Komma bleibt alles in der ersten Zeile - + lieber ein langer erster Eintrag als ein erfundener Ort. */ +function adresseTeilen(adresse) { + const roh = (adresse || "").trim(); + if (!roh) return { strasse: "", ort: "" }; + const komma = roh.lastIndexOf(","); + if (komma < 0) return { strasse: roh, ort: "" }; + return { strasse: roh.slice(0, komma).trim(), ort: roh.slice(komma + 1).trim() }; +} + +function tankstelleZeilen(name, adresse) { + const { strasse, ort } = adresseTeilen(adresse); + const oben = [(name || "").trim(), strasse].filter(Boolean).join(", "); + return { oben: oben || "unbekannt", unten: ort }; +} + +/* Suchbegriffe fuer die Geokodierung, in der Reihenfolge des Versuchs: erst + die Anschrift, dann der Name. NICHT beides zusammen - am 04.09.2026 + gemessen: "Pascalstr. 8, 85057 Ingolstadt" findet Nominatim, mit dem + Betreibernamen davor nicht. Der Name ALLEIN ist trotzdem wertvoll + ("Shell, SOELDEN" findet es), deshalb der zweite Versuch. */ +function tankstelleKandidaten(name, adresse) { + return [(adresse || "").trim(), (name || "").trim()].filter(Boolean); +} + +/** Der erste Suchbegriff - leer heisst: der Beleg nennt gar nichts. */ +function tankstelleSuchtext(name, adresse) { + return tankstelleKandidaten(name, adresse)[0] || ""; +} + +/* Ziel fuer den Kartendienst. Hier gehoeren Name UND Anschrift zusammen: + Apple Karten und Google Maps kennen Betriebsnamen und treffen damit die + Zapfsaeule statt des Nachbargebaeudes. */ +function tankstelleZiel(name, adresse) { + return [(name || "").trim(), (adresse || "").trim()].filter(Boolean).join(", "); +} + +/* Die Kandidaten der Reihe nach nachschlagen, erster Treffer gewinnt. */ +async function tankstellePosition(name, adresse) { + for (const begriff of tankstelleKandidaten(name, adresse)) { + const pos = await adresseAufloesen(begriff); + if (pos) return pos; + } + return null; +} + +/** Apple-Geraet? Dort ist Karten der Standarddienst. */ +function apfelgeraet() { + const kennung = `${navigator.userAgent || ""} ${navigator.platform || ""}`; + return /iPhone|iPad|iPod|Macintosh|Mac OS X/i.test(kennung); +} + +function kartendienstUrl(name, adresse, pos) { + const beschriftung = (name || "").trim(); + const ziel = pos ? `${pos.lat},${pos.lon}` : tankstelleZiel(name, adresse); + if (!ziel) return null; + if (apfelgeraet()) { + const q = pos && beschriftung ? `&q=${encodeURIComponent(beschriftung)}` : ""; + return `https://maps.apple.com/?daddr=${encodeURIComponent(ziel)}${q}`; + } + return `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(ziel)}`; +} + +function kartendienstUrlZuPunkt(pos) { + return pos ? kartendienstUrl(null, null, pos) : null; +} + /* Setzt Nadel und Ausschnitt, sobald die Koordinaten da sind. Getrennt von initMap(), weil der Abruf asynchron ist und die Karte bis dahin schon steht. */ @@ -3107,13 +3198,24 @@ function vEinst() { Dieselben Felder gibt es unter Einstellungen → Geräte & Dienste → Audi Dashboard → Konfigurieren.
` : ""} -
Fahrt beenden + ${(() => { + /* Der Regler schreibt NICHT mehr beim Loslassen. Sein Wert stellt zugleich + den Schlaf-Timeout des Dongles - eine Änderung am Fahrzeug, die über zwei + Netze geht; die soll bewusst ausgelöst werden und nicht als Nebenwirkung + einer Wischgeste entstehen (Vorgabe des Eigentümers, 04.09.2026). Der + Knopf erscheint erst, wenn der Regler wirklich woanders steht - ein + dauerhaft sichtbarer, meist wirkungsloser Knopf wäre Beiwerk. */ + const minuten = PAUSENZEIT_ENTWURF ?? CONFIG.pausenzeit; + const geaendert = PAUSENZEIT_ENTWURF !== null && PAUSENZEIT_ENTWURF !== CONFIG.pausenzeit; + return `
Fahrt beenden
- - ${CONFIG.pausenzeit} Min. + ${minuten} Min.
+ ${geaendert ? ` + ` : ""} Hier können Sie einstellen, wie viele Minuten nach Ausschalten der Zündung die @@ -3121,7 +3223,8 @@ function vEinst() { des Dongles: so lange bleibt er nach dem Abstellen wach und wartet darauf, dass es gleich weitergeht. Danach schläft er — das spart Strom, kostet beim Fortsetzen aber ein paar Sekunden fürs Aufwachen. -
+
`; + })()} ${DONGLE && DONGLE.aktiv ? `
Dongle ${DONGLE.stand && DONGLE.stand.fehler ? `
+ Keine Position zur Tankstelle +

${ + suche + ? "Zu dieser Anschrift ließ sich kein Kartenpunkt finden. Tragen Sie im Beleg unter „Werte bearbeiten“ eine genauere Anschrift ein, dann erscheint die Tankstelle hier." + : "Dieser Beleg nennt weder Tankstelle noch Anschrift. Tragen Sie sie unter „Werte bearbeiten“ nach, dann erscheint die Tankstelle hier." + }

+
`; +} + +/* Tankstelle des Belegs: eigene Zeile ueber die volle Kachelbreite statt der + engen Wertespalte, zweizeilig und antippbar. + + Vorher eine gewoehnliche .row: Name rechts in der Wertespalte (rows-fest, + also 120px), die ganze Anschrift als Kleintext darunter - "Hermann Mogler + Mineraloelg. GmbH, Heidenheimerstr.6, 89551 Koenigsbronn" stand dort + vier- bis fuenfzeilig. Der Tipp fuehrt jetzt in den Kartendienst des + Geraets; ohne Name und ohne Anschrift bleibt es eine stumme Zeile, denn + ein Verweis, der nirgendwohin fuehrt, waere schlechter als keiner. */ +function tankstellenZeile(f) { + const { oben, unten } = tankstelleZeilen(f.station_name, f.station_address); + const url = kartendienstUrl(f.station_name, f.station_address); + const inhalt = + `${esc(oben)}${unten ? `${esc(unten)}` : ""}` + + (url ? `` : ""); + return `
Tankstelle
${ + url + ? `${inhalt}` + : `${inhalt}` + }
`; +} + function vFill(id) { const f = FILLS.find((x) => x.tank_id === id); if (!f) return `
Tankvorgang nicht gefunden
`; @@ -4420,7 +4570,7 @@ function vFill(id) { } return ` - ${mapBoxMitZentrieren()} + ${belegKarte(f)}
Getankt
${f.liters != null ? de(f.liters, 2) : "–"}l
@@ -4433,8 +4583,8 @@ function vFill(id) {
Getankte Liter
${f.liters != null ? de(f.liters, 2) + " l" : "–"}
Kosten
${f.fuel_total_eur != null ? eur(f.fuel_total_eur) + " €" : "–"}real, nach Rabatt
Preis je Liter
${berechneterPreis != null ? de(berechneterPreis, 2) + " €" : "–"}Kosten ÷ Liter
- ${CAR.smartdeal.aktiv && f.discount != null ? `
${markenlogo()} SmartDeal-Ersparnis
${eur(f.discount)} €
` : ""} -
Tankstelle
${esc(f.station_name || "unbekannt")}${esc(f.station_address || "")}
+ ${CAR.smartdeal.aktiv && f.discount != null ? `
${markenlogo()} SmartDeal-Ersparnis
${eur(f.discount)} €
` : ""} + ${tankstellenZeile(f)} ${belegFehler && belegFehler.context === f.tank_id ? `${esc(belegFehler.meldung)}` : ""} @@ -5140,6 +5290,10 @@ const go = (name, id) => { // no-op, falls die Daten längst da sind (datenLaden() rendert dann // ohnehin nichts doppelt Sichtbares). if (!DATEN_GELADEN) datenLaden(false); + // Der Merker "zu diesem Beleg gibt es keine Position" gilt nur so lange, wie + // man auf ihm steht: nach einer Korrektur der Anschrift soll erneut gesucht + // werden, nicht die alte Absage stehen bleiben. + BELEG_KARTE = null; // Ein unbestätigter Servicebuch-Entwurf (siehe data-sbneu) verlässt die // Anwendung nie ungespeichert - wird die Eintragsseite ohne Klick auf // "Speichern" verlassen, verwerfen wir ihn hier statt ihn liegen zu lassen @@ -5838,6 +5992,9 @@ function ereignisseVerdrahten() { distanz: w.distanz, kraftstoff: w.kraftstoff, receipt_key: beleg ? beleg.receipt_key : null, receipt_file: beleg ? beleg.receipt_file : null, }); + // Die Anschrift kann sich gerade geändert haben - dann soll die Karte + // erneut suchen statt bei der alten Absage zu bleiben (siehe BELEG_KARTE). + BELEG_KARTE = null; tankBearbeitenOffen = false; belegEntwurf = null; belegFehler = null; render(); return; } const belegBtn = e.target.closest("[data-beleg]"); @@ -5988,6 +6145,15 @@ function ereignisseVerdrahten() { } return; } + /* Regler "Fahrt beenden": erst hier wird geschrieben. profilSpeichern() + stellt ueber denselben Weg auch den Schlaf-Timeout des Dongles - genau + deshalb hat der Regler einen eigenen Knopf bekommen. */ + if (e.target.closest("[data-pausenzeitspeichern]")) { + if (PAUSENZEIT_ENTWURF !== null) CONFIG.pausenzeit = PAUSENZEIT_ENTWURF; + PAUSENZEIT_ENTWURF = null; + profilSpeichern(); render(); return; + } + if (e.target.closest("[data-pausenzeitverwerfen]")) { PAUSENZEIT_ENTWURF = null; render(); return; } const satz = e.target.closest("[data-satz]"); if (satz) { serviceRufen("reifen_wechseln", { satz: satz.dataset.satz.toLowerCase() }); CAR.reifen.aktiv = satz.dataset.satz; render(); return; } const rfArch = e.target.closest("[data-reifenarchivieren]"); @@ -6106,12 +6272,12 @@ function ereignisseVerdrahten() { ROOT.addEventListener("input", (e) => { if (e.target.dataset.pausenzeit !== undefined) { // Beim Ziehen nur die Anzeige und die Fuellung nachziehen, NICHT - // render() und nicht speichern: ein Re-Render mitten in der Geste - // erzeugt den Regler neu und der Daumen springt aus der Hand. - // Gespeichert wird beim Loslassen (change-Zweig weiter unten) - - // dieselbe Trennung wie beim Suchfeld direkt darunter. + // render(): ein Re-Render mitten in der Geste erzeugt den Regler neu und + // der Daumen springt aus der Hand. Dieselbe Trennung wie beim Suchfeld + // direkt darunter. Gespeichert wird ueberhaupt nicht mehr hier, sondern + // erst auf Knopfdruck (data-pausenzeitspeichern). const minuten = parseInt(e.target.value, 10); - CONFIG.pausenzeit = minuten; + PAUSENZEIT_ENTWURF = minuten; e.target.style.setProperty("--fuell", (minuten / 60) * 100 + "%"); const feld = e.target.parentElement.querySelector("output"); if (feld) feld.textContent = minuten === 0 ? "sofort" : minuten + " Min."; @@ -6316,7 +6482,9 @@ function ereignisseVerdrahten() { if (e.target.dataset.oelm !== undefined) { CAR.oel.monate = parseInt(e.target.value, 10); profilSpeichern(); return; } if (e.target.dataset.setupNurpassend !== undefined) { setupNurPassend = e.target.checked; render(); return; } if (e.target.dataset.startbild !== undefined) { CONFIG.startbild = e.target.value; profilSpeichern(); render(); return; } - if (e.target.dataset.pausenzeit !== undefined) { CONFIG.pausenzeit = parseInt(e.target.value, 10); profilSpeichern(); return; } + // Loslassen: nur den Entwurf festhalten und neu zeichnen, damit der + // Speichern-Knopf erscheint. Geschrieben wird erst dort. + if (e.target.dataset.pausenzeit !== undefined) { PAUSENZEIT_ENTWURF = parseInt(e.target.value, 10); render(); return; } if (e.target.dataset.svsel !== undefined) { svAuswahl = parseInt(e.target.value, 10); render(); return; } }); } diff --git a/custom_components/audi_dashboard/frontend/audi-dashboard.css b/custom_components/audi_dashboard/frontend/audi-dashboard.css index 3889fd8..2c7f627 100644 --- a/custom_components/audi_dashboard/frontend/audi-dashboard.css +++ b/custom_components/audi_dashboard/frontend/audi-dashboard.css @@ -427,6 +427,29 @@ button.tile, .tilebtn { transition: background .15s, transform .1s; } .row dd { margin: 0; font-size: 14px; color: var(--fg); text-align: right; } .row dd small { display: block; font-size: 12.5px; color: var(--fg3); margin-top: 3px; } .rows-fest dd { min-width: 120px; } + +/* Tankstelle im Einzelbeleg (Vorgabe des Eigentuemers, 04.09.2026). + + Vorher eine gewoehnliche Zeile: Name rechts in der 120px-Wertespalte, die + ganze Anschrift als Kleintext darunter - "Hermann Mogler Mineraloelg. GmbH, + Heidenheimerstr.6, 89551 Koenigsbronn" stand dort vier- bis fuenfzeilig. + Hier bekommt sie die volle Kachelbreite unter der Beschriftung, zwei Zeilen + (Tankstelle und Strasse / Ort), und fuehrt beim Tippen in den Kartendienst - + deshalb der Chevron rechts. Deckungsgleich mit .dm-tankstellenzeile in der + App. */ +.row.tankstellenzeile { flex-direction: column; align-items: stretch; gap: 4px; border-bottom: 0; } +/* flex:0 0 auto hebt .rows-fest dt{flex:0 0 150px} auf: in einer Spalte + ist die Flex-Basis die HOEHE - der Titel waere sonst 150px hoch. */ +.row.tankstellenzeile dt { font-size: 13px; color: var(--fg2); flex: 0 0 auto; } +.row.tankstellenzeile dd { min-width: 0; text-align: left; flex: 0 0 auto; } +.row.tankstellenzeile dd > a, +.row.tankstellenzeile dd > span { + display: flex; align-items: center; justify-content: space-between; + gap: var(--sp-3); color: inherit; text-decoration: none; +} +.row.tankstellenzeile dd > a:active { opacity: .6; } +.tankstellenzeile-ort { display: block; font-size: 14px; color: var(--fg); line-height: 1.35; min-width: 0; } +.tankstellenzeile-ort small { display: block; margin-top: 2px; font-size: 12.5px; color: var(--fg3); } /* .row-fussnote ist mit dem Umbau der Service-Seite auf .termingruppe entfallen (2026-08-18) - dort gibt es keine dt/dd-Spalten mehr, die um Platz konkurrieren. Die beiden Regeln dafuer standen seither ohne @@ -695,7 +718,16 @@ img.carfix.klein { aspect-ratio: auto; height: auto; } .dots i { width: 5px; height: 5px; border-radius: 50%; background: var(--line-strong); } .dots i.on { background: var(--red); } -.marke { display: inline-flex; align-items: center; gap: 6px; } +/* white-space:nowrap: ohne die brach "SmartDeal-Ersparnis" neben dem + Markenlogo um. Kein Platzmangel, sondern ein Rundungsfehler - am + Einzelbeleg gemessen (04.09.2026, 375px breit): die dt-Spalte war 150,00px + breit, ihr eigener Inhalt 150,20px, also rutschte der Text hinter dem Logo + in eine zweite Zeile und die Zeile wurde 44,8px statt 26,4px hoch. Die + Zeile hat reichlich Platz (307px fuer 150 + 16 + 55), sie darf ihn nur nicht + abgeschnitten bekommen - dafuer gibt die Wertespalte in genau dieser Zeile + ihre feste Mindestbreite auf (.row-marke). */ +.marke { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; } +.rows-fest .row-marke dd { min-width: 0; } .marke-logo { display: inline-flex; align-items: center; } /* 19,2px = 16px + 20%. Der Eigentuemer wollte das Markenlogo in JEDER Darstellung um ein Fuenftel groesser; es steht ueberall neben Text der diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json index 5057637..d761343 100644 --- a/custom_components/audi_dashboard/manifest.json +++ b/custom_components/audi_dashboard/manifest.json @@ -1,14 +1,23 @@ { "domain": "audi_dashboard", "name": "Audi Dashboard", - "version": "2026.9.4.1", + "version": "2026.9.4.5", "documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md", "issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues", - "codeowners": ["@paul"], + "codeowners": [ + "@paul" + ], "config_flow": true, "integration_type": "service", "single_config_entry": true, "iot_class": "local_polling", - "dependencies": ["http", "frontend", "panel_custom", "recorder"], - "requirements": ["pypdf>=4.0.0"] + "dependencies": [ + "http", + "frontend", + "panel_custom", + "recorder" + ], + "requirements": [ + "pypdf>=4.0.0" + ] }