Tankstelle zweizeilig und antippbar, Belegkarte ohne toten Knopf (2026.9.4.5)

Sieben Meldungen des Eigentuemers, beide Codebasen.

Karte im Einzelbeleg: "Position laedt nicht" und "der Button funktioniert
nicht" waren dieselbe Stelle - ohne aufloesbaren Ort malte das Panel einen
beliebigen Ausschnitt und liess den Zentrieren-Knopf fuer immer disabled.
Beide zeigen jetzt die Auskunft statt einer Karte und unterscheiden dabei
"steht nicht im Beleg" von "findet niemand". Dazu ein echter Paritaetsmangel:
die App schlug nur station_address nach, das Panel faellt seit jeher auf den
Namen zurueck - von 16 Testbelegen tragen vier eine Anschrift.

Der Suchbegriff darf Name und Anschrift NICHT zusammensetzen: gemessen findet
Nominatim "Pascalstr. 8, 85057 Ingolstadt", mit dem Betreibernamen davor
nichts. Jetzt zwei Kandidaten nacheinander (Anschrift, dann Name); fuer den
Kartendienst dagegen beides zusammen, weil Apple und Google Betriebsnamen
kennen.

Tankstelle steht ueber die volle Kachelbreite, zweizeilig (Tankstelle und
Strasse / Ort) und fuehrt beim Tippen in den Standard-Kartendienst - Apple
Karten auf Apple-Geraeten, sonst Google Maps. Dieselbe Wahl jetzt auch fuer
Fahrzeugstandort und Autohaus.

SmartDeal-Ersparnis brach um, weil die dt-Spalte 150,00px breit war und ihr
Inhalt 150,20px - zwei Zehntel Pixel, Zeile dadurch 44,8 statt 26,4px hoch.

Der Regler "Fahrt beenden" schreibt nicht mehr beim Loslassen: derselbe Wert
stellt den Schlaf-Timeout des Dongles, das soll bewusst ausgeloest werden.
Speichern/Verwerfen erscheinen nur bei echter Aenderung.

Share-Erweiterung: an der ausgelieferten .ipa gemessen trug sie 1.0, die App
2026.9.3.3 - iOS registriert eine Erweiterung mit abweichender Versionsnummer
nicht, sie fehlt dann im Teilen-Blatt. ios-signieren.sh schreibt die Version
jetzt in beide Ziele und liest die fertige .ipa gegen. Zwischenablage: dritter
Weg ueber UIPasteboard.itemProviders, und ein fehlender nativer Teil wird als
solcher gemeldet statt als "Kein Zugriff".

Am Geraet gesetzt: 1003 Network Ping Timeout 0 -> 60 s, 1004 ACK Type
TCP/IP -> AVL. Beide liegen bei flespi als pending bereit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 10:10:55 +02:00
parent 7f386d19ea
commit 43f25e2e0d
17 changed files with 1000 additions and 60 deletions
+151
View File
@@ -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()
})
})
+137
View File
@@ -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 `"<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
}
/** `"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)
}
+13 -2
View File
@@ -67,12 +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. */
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".',
)
+41 -6
View File
@@ -58,6 +58,12 @@ export function Einstellungen({
// eigener Entwurf, der beim Speichern mitgeschrieben wird.
const [erstzulassung, setzeErstzulassung] = useState<string | null>(null)
const [entwurf, setzeEntwurf] = useState<EinstellungenWerte | null>(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<number | null>(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<EinstellungenWerte>) =>
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<EinstellungenWerte>) => {
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 && (
<div className="dm-speicherleiste dm-speicherleiste--kachel">
<ActionButton
onClick={() => void speichern({ pausenzeitMin: pausenzeitEntwurf as number })}
disabled={laeuft}
>
{laeuft ? "Speichere …" : "Speichern"}
</ActionButton>
<button
type="button"
className="dm-textknopf"
onClick={() => setzePausenzeitEntwurf(null)}
disabled={laeuft}
>
Verwerfen
</button>
</div>
)}
{/* Fusszeile statt Info-Knopf: Apples eigenes Muster fuer erklaerenden
Text unter einer Einstellung ist der Footer der Gruppe. */}
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
+2 -1
View File
@@ -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
</a>
+4 -3
View File
@@ -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
+56 -5
View File
@@ -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)}`}
/>
)}
<Wertzeile
label="Tankstelle"
wert={tank.station_name ?? "unbekannt"}
zusatz={tank.station_address ?? ""}
/>
<Tankstellenzeile name={tank.station_name} adresse={tank.station_address} />
</>
}
/>
@@ -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 = (
<>
<span className="dm-tankstellenzeile__ort">
{oben}
{unten && <small>{unten}</small>}
</span>
{url && (
<svg className="dm-blatt__chevron" viewBox="0 0 6 10" aria-hidden="true">
<polyline points="1,1 5,5 1,9" />
</svg>
)}
</>
)
return (
<div className="dm-tankstellenzeile">
<dt>Tankstelle</dt>
<dd>
{url ? (
<a
className="dm-tankstellenzeile__inhalt"
href={url}
target="_blank"
rel="noopener"
aria-label={`${oben} in Karten öffnen`}
>
{inhalt}
</a>
) : (
<span className="dm-tankstellenzeile__inhalt">{inhalt}</span>
)}
</dd>
</div>
)
}
/** "YYYY-MM-DDTHH:mm" in Ortszeit für ein <input type="datetime-local">. */
function alsDatetimeLocal(iso: string): string {
const d = new Date(iso)
+43 -11
View File
@@ -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<Koordinate | null>(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 <div className="dm-kartenplatz" />
if (!geprueft && suche) return <div className="dm-kartenplatz" />
return (
<Leerzustand
titel="Keine Position zur Tankstelle"
text="Zu dieser Anschrift ließ sich kein Kartenpunkt finden. Sobald der Beleg eine auflösbare Adresse trägt, erscheint die Tankstelle hier."
text={
suche
? "Zu dieser Anschrift ließ sich kein Kartenpunkt finden. Trag im Beleg unter „Werte bearbeiten“ eine genauere Anschrift ein, dann erscheint die Tankstelle hier."
: "Dieser Beleg nennt weder Tankstelle noch Anschrift. Trag sie unter „Werte bearbeiten“ nach, dann erscheint die Tankstelle hier."
}
/>
)
}
+71
View File
@@ -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 {