Files
audi-app/companion-app/src/daten/tankstelle.ts
T
tobias a99ae432fc Tankstellen-Nadel, doppeltes Tanken-Symbol, Tankstellenname, packageClassList
Fuenf Meldungen des Eigentuemers, vier davon behoben:

1. Tankstellen-Nadel war bei 32 px ein dunkler Fleck (poi zeichnet nur
   einen Ring). Gefuellter Punkt in currentColor zwischen Silhouette und
   Symbol - die Geometrie stand im Pfad. Rot wurde ausdruecklich
   abgelehnt, die Umsetzung aendert keine Farbe.

2. Tanken-Symbol im Standort-Blatt wurde gefuellt UND umrandet, weil das
   CI-Symbol zusaetzlich stroke-Attribute mitbekam. Jetzt ueber ciSVG
   wie das Parkplatz-Symbol daneben; in der App dafuer imRaster an
   SymbolTanken (der 0,84-Faktor gilt nur in der Reiterleiste).

3. Tankstellenname: der Bestand hat zwei Bauformen - aeltere Belege
   tragen den Betreiber in station_name und die Anschrift daneben.
   Neu tankstelleTeile() liest beide Felder, entfernt Firmierungen,
   kuerzt Strassen und trennt die Hausnummer auch ohne Leerzeichen.
   Uebersicht: Marke, Strasse, Ort. Einzelbeleg: zwei Zeilen.
   adresseTeilen() hat keinen Aufrufer mehr und ist entfernt.

4. Zwischenablage und Kalendereintrag hatten denselben Grund: Capacitor 8
   registriert nur, was in packageClassList steht, und cap sync traegt
   dort ausschliesslich npm-Pakete ein. Beide app-eigenen Plugins lagen
   im Programm und wurden nie registriert. ios-teilen-einrichten.mjs
   traegt sie jetzt nach, ios-signieren.sh bricht ab, wenn sie im
   fertigen .ipa fehlen.

5. Geteilter Beleg nur mit Datum und Uhrzeit bleibt offen - der Weg
   nutzt denselben belegLesen()-Pfad wie eine gewaehlte Datei, es fehlt
   der konkrete Beleg oder die Protokollzeile.

tsc sauber, 246 Tests, vite build sauber, Panel als Modul geparst,
audi_ha_test auf 2026.9.4.20 ohne Traceback. Panel und App liefern fuer
die Tankstelle byteweise dasselbe.
2026-09-04 16:34:46 +02:00

343 lines
14 KiB
TypeScript

/**
* 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.
*/
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.
*/
export function tankstelleTeile(
name: string | null | undefined,
adresse: string | null | undefined,
): Tankstellenteile {
const teile: Tankstellenteile = {
marke: 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(",")) {
const t = stueck.trim()
// Die Marke steht schon fest; als Ort taugt sie nicht.
if (teile.marke && t.toLowerCase() === teile.marke.toLowerCase()) continue
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,
): string {
const t = tankstelleTeile(name, adresse)
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,
): { oben: string; unten: string } {
const t = tankstelleTeile(name, adresse)
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,
): string {
const a = (adresse ?? "").trim()
if (!a) return (name ?? "").trim()
return [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",
): string | null {
const beschriftung = (name ?? "").trim()
const ziel = pos ? `${pos.lat},${pos.lon}` : tankstelleZiel(name, adresse)
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)
}