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.
This commit is contained in:
2026-09-04 16:34:46 +02:00
parent cefb235f6d
commit a99ae432fc
14 changed files with 543 additions and 78 deletions
+60 -27
View File
@@ -9,11 +9,12 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import {
adresseTeilen,
kartendienstUrl,
markeErkennen,
tankstelleKandidaten,
tankstelleSuchtext,
tankstelleKurz,
tankstelleTeile,
tankstelleZeilen,
tankstelleZiel,
} from "./tankstelle"
@@ -26,31 +27,6 @@ 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("markeErkennen", () => {
it("findet die Kopfmarke im gespeicherten Namen", () => {
expect(markeErkennen("Shell, SÖLDEN")).toBe("Shell")
@@ -92,7 +68,8 @@ describe("tankstelleZeilen", () => {
it("zeigt ohne Anschrift den gespeicherten Namen unveraendert", () => {
// Sonst hiesse eine bekannte Tankstelle plötzlich "unbekannt".
expect(tankstelleZeilen("Shell, SÖLDEN", null)).toEqual({ oben: "Shell, SÖLDEN", unten: "" })
// 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: "" })
})
@@ -216,3 +193,59 @@ describe("kartendienstUrl", () => {
)
})
})
/* 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",
})
})
})
+142 -20
View File
@@ -72,38 +72,160 @@ export function markeErkennen(name: string | null | undefined): string | null {
return null
}
/** `"Pascalstr. 8, 85057 Ingolstadt"` → Straße und Ort. */
export function adresseTeilen(adresse: string | null | undefined): {
/* 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
} {
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() }
/** Der gespeicherte Name, unveraendert - der letzte Rueckfall. */
roh: string
}
/**
* Die beiden Zeilen der Tankstellen-Angabe (Vorgabe des Eigentümers):
* oben Kopfmarke und Straße mit Hausnummer, unten Postleitzahl und Ort.
* Zerlegt, was ein Beleg ueber die Tankstelle hergibt.
*
* Ohne erkennbare Marke steht oben nur die Straße - die Firmierung des
* Betreibers tritt NICHT an ihre Stelle, sie ist nach außen unsichtbar.
* Trägt der Beleg gar keine Anschrift, bleibt der gespeicherte Name die
* einzige Auskunft und wird unverändert gezeigt ("Shell, SÖLDEN"); ihn dann
* wegzulassen hieße, eine bekannte Tankstelle "unbekannt" zu nennen.
* WARUM BEIDE FELDER GELESEN WERDEN
* ---------------------------------
* Der Bestand hat zwei Bauformen, am 04.09.2026 an den echten Datensaetzen
* nachgesehen:
*
* `oben` ist nie leer: nennt der Beleg nichts, heißt es dort "unbekannt".
* 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 { strasse, ort } = adresseTeilen(adresse)
if (!strasse && !ort) return { oben: (name ?? "").trim() || "unbekannt", unten: "" }
const oben = [markeErkennen(name) ?? "", strasse].filter(Boolean).join(", ")
return { oben: oben || "unbekannt", unten: ort }
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 }
}
/**
+15 -8
View File
@@ -67,16 +67,23 @@ function base64ZuDatei(base64: string, name: string): File {
*/
export async function pdfAusZwischenablage(): Promise<File> {
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. */
/* 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 enthält den nativen Teil noch nicht - er kommt nur mit " +
'einer neu installierten App, nicht über die Aktualisierung. Bis dahin "Datei auswählen".',
'Diese App-Fassung kann die Zwischenablage nicht abfragen - bitte ' +
'"Datei auswählen".',
)
}
let ergebnis: { base64?: string; name?: string }
+8
View File
@@ -38,9 +38,17 @@ const CI_POI = '<path fill="currentColor" d="M4 8.5C4 4.32 7.32 1 11.5 1S19 4.32
const CI_GPS = 'M12.5 3.51V6.5h-1V3.51a8.46 8.46 0 0 0-7.99 7.99H6.5v1H3.51a8.46 8.46 0 0 0 7.99 7.99V17.5h1v2.99a8.46 8.46 0 0 0 7.99-7.99H17.5v-1h2.99a8.46 8.46 0 0 0-7.99-7.99m0-1a9.46 9.46 0 0 1 8.99 8.99h2.01v1h-2.01a9.46 9.46 0 0 1-8.99 8.99v2.01h-1v-2.01a9.46 9.46 0 0 1-8.99-8.99H.5v-1h2.01a9.46 9.46 0 0 1 8.99-8.99V.5h1z'
function nadelSvg(px = 32): string {
// Der Ring im poi-Icon ist bei Kartengroesse zu duenn, um sich von der
// dunklen Silhouette abzuheben - die Nadel las sich als schwarzer Fleck
// (Nutzerbefund 04.09.2026). Der gefuellte Punkt schliesst genau den
// Kreis, der im Pfad ohnehin steht (poi-s: 11.5/8.5 r3.5, poi-l:
// 23.5/17.5 r7.5) - erfunden ist daran nichts, und keine Farbe aendert
// sich: currentColor ist dasselbe Weiss wie die uebrigen Konturlinien.
const punkt = '<circle cx="11.5" cy="8.5" r="2.9" fill="currentColor"/>'
return (
`<svg viewBox="0 0 24 24" width="${px}" height="${px}" fill="none" aria-hidden="true">` +
CI_POI_SIL +
punkt +
CI_POI +
"</svg>"
)
+1 -1
View File
@@ -598,7 +598,7 @@ export function Standort() {
)}
</div>
<div className="dm-standortmenu__werte">
<SymbolTanken groesse={16} />
<SymbolTanken groesse={16} imRaster />
<span>{de(fahrzeug.tankPct)} %</span>
<span>{fahrzeug.reichweite != null ? `${de(fahrzeug.reichweite)} km` : "Reichweite unbekannt"}</span>
</div>
+2 -1
View File
@@ -24,6 +24,7 @@ import {
import { Blattzeile, KRAFTSTOFFSORTEN, Leerzustand } from "./bausteine"
import { BelegPopup } from "./BelegPopup"
import { markenlogoUrl } from "./bilder"
import { tankstelleKurz } from "../daten/tankstelle"
import { LOESCH_HINWEIS } from "./bestaetigung"
import { Zeilenmenue } from "./Zeilenmenue"
@@ -124,7 +125,7 @@ export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
kinder={
<Blattzeile
label={datum(tank.ts)}
labelZusatz={tank.station_name ?? ""}
labelZusatz={tankstelleKurz(tank.station_name, tank.station_address)}
wert={tank.liters != null ? `${de(tank.liters, 2)} l` : ""}
wertZusatz={preis != null ? `${de(preis, 2)} €/l` : ""}
beiKlick={() => geheZu("fill", tank.tank_id)}
+13 -2
View File
@@ -167,14 +167,25 @@ export function SymbolPaket({ groesse }: SymbolProps) {
Die 48er-Variante hieße fuel-station-l und wird nicht gebraucht: die
Reiter zeichnen mit 21 bzw. 26px, also im 24er-Raster. */
export function SymbolTanken({ groesse }: SymbolProps) {
/* Der 0,84-Faktor ist die Kalibrierung fuer die Reiterleiste: dort steht das
Symbol neben vier anderen und waere roh das hoechste (Befund 02.09.2026,
19x22 gegen 24er-Raster). Ausserhalb der Leiste stimmt er nicht - im
Standort-Blatt steht daneben das randfuellende Parkplatz-Symbol (23x23),
und die geskalierte Fassung (16,0x18,5) wirkte daneben zu klein.
`imRaster` laesst das Symbol seine eigene Groesse behalten. */
export function SymbolTanken({ groesse, imRaster = false }: SymbolProps & { imRaster?: boolean }) {
const pfad = (
<path fill="currentColor" d="M4 3.5A2.5 2.5 0 0 1 6.5 1h6A2.5 2.5 0 0 1 15 3.5V10h.5a2.5 2.5 0 0 1 2.5 2.5v5a1.5 1.5 0 0 0 3 0V8.07a16.4 16.4 0 0 0-2.89-5.76l.78-.62a17.4 17.4 0 0 1 3.1 6.17l.01.07v9.57a2.5 2.5 0 0 1-5 0v-5c0-.83-.67-1.5-1.5-1.5H15v11h1v1H3v-1h1zM5 22h9V3.5c0-.83-.67-1.5-1.5-1.5h-6C5.67 2 5 2.67 5 3.5zm1-12h7v1H6z" />
)
return (
<RahmenFlaeche
groesse={groesse}
kinder={
imRaster ? pfad : (
<g transform="translate(1.5,1.92) scale(0.84)">
<path fill="currentColor" d="M4 3.5A2.5 2.5 0 0 1 6.5 1h6A2.5 2.5 0 0 1 15 3.5V10h.5a2.5 2.5 0 0 1 2.5 2.5v5a1.5 1.5 0 0 0 3 0V8.07a16.4 16.4 0 0 0-2.89-5.76l.78-.62a17.4 17.4 0 0 1 3.1 6.17l.01.07v9.57a2.5 2.5 0 0 1-5 0v-5c0-.83-.67-1.5-1.5-1.5H15v11h1v1H3v-1h1zM5 22h9V3.5c0-.83-.67-1.5-1.5-1.5h-6C5.67 2 5 2.67 5 3.5zm1-12h7v1H6z" />
{pfad}
</g>
)
}
/>
)