Pruefdurchgang ueber alle 26 Bildschirme - fuenf Fehler behoben, HIG nachgezogen

Gemessen statt geschaut: eine Pruefroutine lief auf jedem Bildschirm und
pruefte Ueberlauf, Tippziele, abgeschnittenen Text, Bilder ohne Quelle und
WCAG-Kontrast fuer jeden Textknoten. Dazu die Live-App mit echten Daten, die
Musterseite fuer Leerzustaende und das Panel zum Abgleich.

1. Deutsche Datumsangaben wurden falsch gelesen. new Date("01.03.2025")
   liest amerikanisch: Tage bis 12 wurden still vertauscht, ab Tag 13 fiel
   der Wert ganz weg. Die Erstzulassung stand auf zwei Bildschirmen
   unterschiedlich da. alsZeitpunkt() erkennt jetzt ISO, TT.MM.JJJJ und
   MM/JJJJ, weist unmoegliche Daten ab und fuehrt die Genauigkeit mit -
   aus "08/2026" wird "August 2026", kein erfundener Erster.

2. Die Hauptuntersuchung war in beiden Oberflaechen unerreichbar. Das Panel
   leitete sie aus der Erstzulassung ab, aber nur bei MM/JJJJ, und benutzte
   den eingetragenen Wert gar nicht. Jetzt beide: eingetragene Faelligkeit
   vor Servicebuch vor Erstzulassung + 24 Monate.

3. Die Tankstellenmarke stand zweimal da, wenn der Name kein Komma hat
   ("Shell München Ost"). ohneMarkeVorn() nimmt sie vorn ab.

4. Bilder wurden geholt, obwohl beide wussten, dass es sie nicht gibt -
   fuenf 404 fuer dieselbe Datei in einem Ladevorgang. Jetzt gar keine
   Adresse bei Stand 0; nachgemessen 4 Anfragen, alle 200.

5. Ein Bildelement ohne Quelle zeigte das kaputte Bildsymbol, und onerror
   greift dort nicht: ohne src gibt es keinen Ladeversuch.

Dazu: leere Klammern beim Steuersatz, leere graue Kachel bei leerer
Leistungsgruppe, Umbruch auf dem Einrichtungsbildschirm, drei
Leerdarstellungen in einer Kachel, fehlende Grundschrift.

Apple HIG: Kontrast war an einer Stelle 3,18 statt 4,5, weisse Schrift auf
der Loeschflaeche 3,41/3,55. Rot als Schrift und Rot als Flaeche sind jetzt
getrennte Token (--bad / --bad-flaeche), beide Apples systemRed fuer
erhoehten Kontrast. Tippziele: unsichtbares 44x44-Overlay, wo die sichtbare
Groesse Teil des Bildes ist, echte Mindesthoehe, wo das Element Flaeche ist -
Formulare werden dadurch sichtbar hoeher.

Neu auf Wunsch: Wischen in der Bildergalerie (beide Richtungen) und die
HIG-konforme Loeschgeste - ein voller Wisch loescht ohne zweiten Tipp, ab
55 % der Zeilenbreite, mit wachsender roter Flaeche und erhaltener
Rueckfrage. Dabei fiel auf, dass der Zugwert aus dem React-Zustand gelesen
wurde und ein schneller Wisch dadurch verlorenging; er liegt jetzt in einer
Referenz.

Paritaet: alle 26 Routen und Titel decken sich, Fahrzeugstatus zeilengleich,
zwei ungeplante Abweichungen (Datumsformatierung, Herkunft der
Hauptuntersuchung) geschlossen.

tsc sauber, 271 Tests, vite build sauber, Design-System gebaut, Panel als
Modul geparst, audi_ha_test auf 2026.9.4.23 ohne Traceback. Live nachgemessen:
Datum auf beiden Bildschirmen gleich, keine 404 mehr, Zurueck-Pfeil sichtbar
34x34 und treffbar 44x44, Galerie wischt in beide Richtungen, voller Wisch
loest die Rueckfrage aus, keine Kontrast-Unterschreitung mehr.
This commit is contained in:
2026-09-04 18:11:07 +02:00
parent ffaed3f544
commit b0ec8b4982
26 changed files with 1125 additions and 114 deletions
@@ -11,6 +11,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"
import {
kartendienstUrl,
markeErkennen,
ohneMarkeVorn,
tankstelleKandidaten,
tankstelleSuchtext,
tankstelleKurz,
@@ -306,3 +307,32 @@ describe("gespeicherte Marke (station_brand)", () => {
)
})
})
describe("Marke am Anfang eines Namensteils", () => {
/* Am 04.09.2026 an der Musterseite aufgefallen: ein von Hand eingetragener
Name ohne Komma rutschte komplett als "Ort" durch. */
it("laesst die Marke nicht zweimal erscheinen", () => {
expect(tankstelleKurz("Shell München Ost", "Musterstraße 5")).toBe(
"Shell, Musterstr., München Ost",
)
})
it("nimmt der Zeile die Marke ab, laesst den Ort stehen", () => {
expect(ohneMarkeVorn("Shell München Ost")).toBe("München Ost")
expect(ohneMarkeVorn("Shell, SÖLDEN")).toBe("SÖLDEN")
expect(ohneMarkeVorn("Shell")).toBe("")
})
it("laesst einen Teil in Ruhe, in dem die Marke nicht vorn steht", () => {
// Dort ist nicht sicher, was Marke und was Ortsname ist.
expect(ohneMarkeVorn("Autohof Shell Nord")).toBe("Autohof Shell Nord")
expect(ohneMarkeVorn("Musterstraße 5")).toBe("Musterstraße 5")
})
it("achtet auf die Wortgrenze", () => {
// "Star" darf nicht in "Starnberg" treffen.
expect(ohneMarkeVorn("Starnberg")).toBe("Starnberg")
expect(ohneMarkeVorn("Sprintstraße 5")).toBe("Sprintstraße 5")
})
})
+28 -7
View File
@@ -69,6 +69,30 @@ const MARKEN = [
* geraten wird nur, wo es fehlt: bei Vorgaengen ohne Beleg, bei von Hand
* eingetragenen Tankstellen und solange das Nachtragen nicht gelaufen ist.
*/
/**
* Nimmt einem Namensteil die fuehrende Kopfmarke ab.
*
* WARUM NICHT NUR VERGLEICHEN: bis zum 04.09.2026 wurde ein Namensteil nur
* dann uebersprungen, wenn er FUER SICH eine Marke ist ("Shell"). Ein von
* Hand eingetragener Name wie "Shell München Ost" hat aber kein Komma - er
* rutschte komplett durch und landete als ORT in der Zeile:
*
* "Shell München Ost" + "Musterstraße 5"
* -> "Shell, Musterstr., Shell München Ost"
*
* Jetzt faellt die Marke vorn ab und der Rest bleibt der Ort. Steht die Marke
* NICHT am Anfang ("Autohof Shell Nord"), bleibt der Teil unangetastet -
* dort ist nicht sicher, was Marke und was Ortsname ist.
*/
export function ohneMarkeVorn(teil: string): string {
const marke = markeErkennen(teil)
if (!marke) return teil
// Kein Maskieren noetig: `marke` stammt immer aus MARKEN, und dort steht
// kein einziges Sonderzeichen der regulaeren Ausdruecke.
const muster = new RegExp(`^${marke}\\b[\\s,]*`, "i")
return muster.test(teil) ? teil.replace(muster, "").trim() : teil
}
export function markeErkennen(name: string | null | undefined): string | null {
const roh = (name ?? "").trim()
if (!roh) return null
@@ -162,13 +186,10 @@ export function tankstelleTeile(
const quellen: string[] = []
for (const stueck of (adresse ?? "").split(",")) quellen.push(stueck)
for (const stueck of (name ?? "").split(",")) {
const t = stueck.trim()
// Ein Teil, der fuer sich genommen eine Kopfmarke IST, taugt nie als Ort.
// Frueher stand hier nur der Vergleich mit der gewaehlten Marke; seit die
// gespeicherte Angabe vorgeht, koennen beide auseinanderfallen, und dann
// rutschte die Marke aus dem Namen in den Ort.
if (t && markeErkennen(t)?.toLowerCase() === t.toLowerCase()) continue
quellen.push(t)
// Die Marke faellt vorn ab - sie steht schon fest und taugt nie als Ort.
// Bleibt nichts uebrig, war der Teil nur die Marke selbst.
const t = ohneMarkeVorn(stueck.trim())
if (t) quellen.push(t)
}
for (const stueck of quellen) {
+78 -1
View File
@@ -2,7 +2,20 @@ import { describe, expect, it } from "vitest"
import { alsCsv } from "./screens/csv"
import { kalenderDatei } from "./screens/kalender"
import { dauer, de, deOderStrich, eur, isoTag, schnittpreis, summe } from "./format"
import {
datum,
datumLang,
datumZeit,
dauer,
de,
deOderStrich,
eur,
isoTag,
monatJahr,
schnittpreis,
summe,
uhrzeit,
} from "./format"
describe("Zahlen im deutschen Format", () => {
it("setzt Punkt als Tausender- und Komma als Dezimaltrennzeichen", () => {
@@ -101,3 +114,67 @@ describe("Kalenderdatei", () => {
)
})
})
describe("Datumsangaben aus dem Bestand", () => {
/* Alle Faelle am 04.09.2026 an der laufenden Instanz gemessen - vorher
vertauschte `new Date()` Tag und Monat, oder verwarf den Wert ganz. */
it("liest deutsche Tagesdaten richtig herum", () => {
// Der gemeldete Fall: die Erstzulassung stand als 03.01.2025 da.
expect(datum("01.03.2025")).toBe("01.03.2025")
expect(datum("12.11.2024")).toBe("12.11.2024")
expect(datum("1.3.2025")).toBe("01.03.2025")
})
it("verliert Tage ab dem 13. nicht mehr", () => {
// Die waren vorher ungueltig und verschwanden als "".
expect(datum("25.03.2025")).toBe("25.03.2025")
expect(datum("31.12.2023")).toBe("31.12.2023")
})
it("zeigt monatsgenaue Angaben als Monat, nicht als erfundenen Tag", () => {
// Die Hauptuntersuchung steht als "MM/JJJJ" im Profil (so verlangt es das
// Einrichtungsfeld). Vorher: "". Ein "01.08.2026" waere eine Erfindung.
expect(datum("08/2026")).toBe("August 2026")
expect(datum("08.2026")).toBe("August 2026")
expect(datum("2026-08")).toBe("August 2026")
expect(datumLang("08/2026")).toBe("August 2026")
})
it("gibt einer monatsgenauen Angabe keine Uhrzeit", () => {
// 00:00 waere erfunden.
expect(uhrzeit("08/2026")).toBe("")
expect(datumZeit("08/2026")).toBe("August 2026")
})
it("laesst ISO vom Backend unveraendert richtig", () => {
expect(datum("2026-08-14")).toBe("14.08.2026")
expect(datum("2024-09-25T15:47:00")).toBe("25.09.2024")
expect(datumZeit("2024-09-25T15:47:00")).toBe("25.09.2024, 15:47 Uhr")
})
it("weist unmoegliche Daten ab, statt still ein anderes zu zeigen", () => {
// new Date(2025,1,31) waere in JavaScript der 3. Maerz.
expect(datum("31.02.2025")).toBe("")
expect(datum("32.01.2025")).toBe("")
expect(datum("00.01.2025")).toBe("")
expect(datum("01.13.2025")).toBe("")
})
it("bleibt bei Unbekanntem beim Strich", () => {
expect(datum(null)).toBe("")
expect(datum("")).toBe("")
expect(datum("demnaechst")).toBe("")
expect(datum("01/03/2025")).toBe("")
})
it("nimmt Date und Zahl weiterhin an", () => {
expect(datum(new Date(2025, 2, 1))).toBe("01.03.2025")
expect(datum(new Date(2025, 2, 1).getTime())).toBe("01.03.2025")
})
it("monatJahr kann beide Genauigkeiten", () => {
expect(monatJahr("2026-08-14")).toBe("August 2026")
expect(monatJahr("08/2026")).toBe("August 2026")
})
})
+115 -13
View File
@@ -88,35 +88,137 @@ const UHRZEIT = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-d
const MONAT_JAHR = new Intl.DateTimeFormat("de-DE", { month: "long", year: "numeric" })
function alsDatum(wert: string | number | Date | null | undefined): Date | null {
/**
* Datumsangaben aus dem Bestand kommen in DREI Formen - deshalb reicht
* `new Date(wert)` hier nicht.
*
* WAS AM 04.09.2026 GEMESSEN WURDE
* --------------------------------
* `new Date()` liest einen nicht-ISO-Text nach amerikanischer Lesart, also
* Monat vor Tag. Fuer deutsche Eingaben heisst das:
*
* "01.03.2025" (1. Maerz) -> 3. Januar 2025 - Tag und Monat vertauscht
* "12.11.2024" -> 11. Dezember - dito
* "25.03.2025" -> ungueltig - Wert verschwand als "-"
* "08/2026" (Hauptunters.) -> ungueltig - dito
*
* Tage bis 12 wurden also STILL vertauscht, ab Tag 13 fiel der Wert ganz weg.
* Sichtbar war es an der Erstzulassung: "Mein Audi" zeigte den Rohwert
* richtig, "Identitaet und Technik" formatierte ihn und drehte ihn dabei um -
* derselbe Wert, zwei Ergebnisse. Und die Hauptuntersuchung stand als "-" da,
* obwohl "08/2026" gespeichert war.
*
* WARUM EIGENE MUSTER STATT EINER BIBLIOTHEK
* ------------------------------------------
* Es sind genau drei Formen, alle aus diesem Projekt: ISO vom Backend,
* "TT.MM.JJJJ" aus den Eingabefeldern, "MM/JJJJ" fuer die Hauptuntersuchung
* (so verlangt es das Einrichtungsfeld, siehe Panel `einErstzulassung`).
* Erkannt wird nur, was hier wirklich vorkommt - alles andere bleibt `null`
* und damit "-". Lieber kein Datum als ein erfundenes.
*
* MONATSGENAU IST EINE EIGENE GENAUIGKEIT
* ---------------------------------------
* "08/2026" nennt keinen Tag. Daraus den 1. August zu machen und
* "01.08.2026" anzuzeigen waere eine Erfindung - deshalb traegt das Ergebnis
* seine Genauigkeit mit sich, und `datum()` zeigt dann "August 2026".
*/
type Genauigkeit = "tag" | "monat"
interface Zeitpunkt {
zeit: Date
genau: Genauigkeit
}
/* ISO vom Backend, mit oder ohne Uhrzeit - das kann `new Date` selbst, und
zwar nach Norm statt nach Gutduenken. */
const ISO = /^\d{4}-\d{2}-\d{2}([T ]|$)/
/* "1.3.2025" wie "01.03.2025". */
const DE_TAG = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/
/* "08/2026" und "08.2026" - Monat und Jahr, ohne Tag. */
const DE_MONAT = /^(\d{1,2})[./](\d{4})$/
/* "2026-08" - dieselbe Genauigkeit, in ISO-Schreibweise. */
const ISO_MONAT = /^(\d{4})-(\d{1,2})$/
/**
* Baut das Datum und weist die Rollover-Falle ab: `new Date(2025, 1, 31)` ist
* in JavaScript der 3. Maerz, nicht der 31. Februar. Ein unmoegliches Datum
* soll "-" ergeben, nicht klammheimlich ein anderes.
*/
function bauen(jahr: number, monat: number, tag: number): Date | null {
if (monat < 1 || monat > 12 || tag < 1 || tag > 31) return null
const d = new Date(jahr, monat - 1, tag)
if (d.getFullYear() !== jahr || d.getMonth() !== monat - 1 || d.getDate() !== tag) return null
return d
}
function alsZeitpunkt(wert: string | number | Date | null | undefined): Zeitpunkt | null {
if (wert === null || wert === undefined || wert === "") return null
const datum = wert instanceof Date ? wert : new Date(wert)
return Number.isNaN(datum.getTime()) ? null : datum
if (wert instanceof Date) return Number.isNaN(wert.getTime()) ? null : { zeit: wert, genau: "tag" }
if (typeof wert === "number") {
const d = new Date(wert)
return Number.isNaN(d.getTime()) ? null : { zeit: d, genau: "tag" }
}
const text = wert.trim()
if (!text) return null
if (ISO.test(text)) {
const d = new Date(text)
return Number.isNaN(d.getTime()) ? null : { zeit: d, genau: "tag" }
}
const tag = DE_TAG.exec(text)
if (tag) {
const d = bauen(Number(tag[3]), Number(tag[2]), Number(tag[1]))
return d ? { zeit: d, genau: "tag" } : null
}
const monat = DE_MONAT.exec(text)
if (monat) {
const d = bauen(Number(monat[2]), Number(monat[1]), 1)
return d ? { zeit: d, genau: "monat" } : null
}
const isoMonat = ISO_MONAT.exec(text)
if (isoMonat) {
const d = bauen(Number(isoMonat[1]), Number(isoMonat[2]), 1)
return d ? { zeit: d, genau: "monat" } : null
}
return null
}
function alsDatum(wert: string | number | Date | null | undefined): Date | null {
return alsZeitpunkt(wert)?.zeit ?? null
}
export function datum(wert: string | number | Date | null | undefined): string {
const d = alsDatum(wert)
return d ? DATUM_KURZ.format(d) : ""
const z = alsZeitpunkt(wert)
if (!z) return ""
return z.genau === "monat" ? MONAT_JAHR.format(z.zeit) : DATUM_KURZ.format(z.zeit)
}
export function datumLang(wert: string | number | Date | null | undefined): string {
const d = alsDatum(wert)
return d ? DATUM_LANG.format(d) : ""
const z = alsZeitpunkt(wert)
if (!z) return ""
return z.genau === "monat" ? MONAT_JAHR.format(z.zeit) : DATUM_LANG.format(z.zeit)
}
export function uhrzeit(wert: string | number | Date | null | undefined): string {
const d = alsDatum(wert)
return d ? UHRZEIT.format(d) : ""
const z = alsZeitpunkt(wert)
// Eine monatsgenaue Angabe hat keine Uhrzeit - 00:00 waere erfunden.
return z && z.genau === "tag" ? UHRZEIT.format(z.zeit) : ""
}
export function datumZeit(wert: string | number | Date | null | undefined): string {
const d = alsDatum(wert)
return d ? `${DATUM_KURZ.format(d)}, ${UHRZEIT.format(d)} Uhr` : ""
const z = alsZeitpunkt(wert)
if (!z) return ""
if (z.genau === "monat") return MONAT_JAHR.format(z.zeit)
return `${DATUM_KURZ.format(z.zeit)}, ${UHRZEIT.format(z.zeit)} Uhr`
}
export function monatJahr(wert: string | number | Date | null | undefined): string {
const d = alsDatum(wert)
return d ? MONAT_JAHR.format(d) : ""
const z = alsZeitpunkt(wert)
return z ? MONAT_JAHR.format(z.zeit) : ""
}
/** Dauer in Sekunden als „2 h 14 min" bzw. „14 min". */
+3 -10
View File
@@ -17,9 +17,9 @@ import { OtaFehler, buendelAnwenden, buendelPasst, otaMoeglich } from "../daten/
import type { Einstellungen as EinstellungenWerte } from "../daten/profilAdapter"
import { datum, datumZeit, de, isoTag, standAlter } from "../format"
import { useTheme } from "../theme"
import { BildMitMenue, Wertzeile, Werteliste } from "./bausteine"
import { BildMitMenue, Markenlogo, Werteliste, Wertzeile } from "./bausteine"
import { bestaetigen } from "./bestaetigung"
import { BILDPLAETZE, SEITE_WINTER, markenlogoUrl } from "./bilder"
import { BILDPLAETZE, SEITE_WINTER } from "./bilder"
import { DatensatzPopup } from "./DatensatzPopup"
import { HistorienImport } from "./HistorienImport"
import type { ServicebuchEintrag } from "./Service"
@@ -530,14 +530,7 @@ export function Einstellungen({
<Feld
label={
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
<img
src={markenlogoUrl()}
alt={werte.markenname}
className="dm-markenlogo"
onError={(e) => {
e.currentTarget.style.display = "none"
}}
/>
<Markenlogo alt={werte.markenname} className="dm-markenlogo" />
{/* Das Panel nennt den Schalter "SmartDeal aktiv" - fest, nicht
aus dem Markennamen gebaut. */}
SmartDeal aktiv
+49 -6
View File
@@ -13,7 +13,7 @@
* Werteliste + Chevron statt einer Zeile Fließtext.
*/
import { useState } from "react"
import { useRef, useState, type PointerEvent as ReactPointerEvent } from "react"
import { Tile } from "@audi-dash/ui"
@@ -58,6 +58,36 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) =
return wert
})
}
/* Wischen in der Bildergalerie (Wunsch des Eigentuemers, 04.09.2026).
Zeigerereignisse statt Beruehrungsereignisse: dieselbe Verdrahtung
traegt Finger, Stift und Maus, und genau so macht es das Standort-
Blatt schon (Standort.tsx). Ein Wisch ab 40 px zaehlt als Wisch, alles
darunter bleibt ein Tipp - dieselbe Schwelle wie dort.
Ueberwiegt die senkrechte Bewegung, war es ein Scrollen und kein
Blaettern - dann passiert nichts.
`gewischt` schluckt den anschliessenden Klick: ein Wisch loest auf dem
Knopf auch ein click aus, und ohne diese Sperre schaltete die Galerie
zweimal weiter. Wortgleich mit galerieWischenVerdrahten() im Panel. */
const zug = useRef<{ x0: number; y0: number } | null>(null)
const gewischt = useRef(false)
const weiter = (richtung: number) =>
setzeBildIndex((i) => (i + richtung + GALERIE.length) % GALERIE.length)
const zugStart = (e: ReactPointerEvent) => {
zug.current = { x0: e.clientX, y0: e.clientY }
}
const zugEnde = (e: ReactPointerEvent) => {
const z = zug.current
zug.current = null
if (!z) return
const dx = e.clientX - z.x0
if (Math.abs(dx) <= 40 || Math.abs(e.clientY - z.y0) > Math.abs(dx)) return
gewischt.current = true
weiter(dx < 0 ? 1 : -1)
}
if (!fahrzeug || !einstellungen) return null
const aktiverSatz =
@@ -128,8 +158,19 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) =
<button
type="button"
className="dm-galerie-wechsel"
onClick={() => setzeBildIndex((i) => (i + 1) % GALERIE.length)}
aria-label="Nächstes Bild"
onPointerDown={zugStart}
onPointerUp={zugEnde}
onPointerCancel={() => {
zug.current = null
}}
onClick={() => {
if (gewischt.current) {
gewischt.current = false
return
}
weiter(1)
}}
aria-label="Nächstes Bild — oder nach links/rechts wischen"
>
<Bild datei={platz.datei} beschreibung={platz.beschreibung} name={platz.beschreibung} />
</button>
@@ -167,9 +208,11 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) =
Hinweis, dass es sie gibt, statt einer abgeschnittenen
Motorzeile. */}
<Wertzeile label="Details" wert="Informationen über das Auto" hinweis />
{/* Leerer Wert statt Strich, wie vAudi(): das Panel gibt hier
den Rohwert aus. */}
<Wertzeile label="Erstzulassung" wert={fahrzeug.erstzulassung} />
{/* Formatiert und mit Strich, wenn nichts da ist - vorher stand
hier der Rohwert und bei leerem Feld gar nichts, waehrend
FIN daneben "" und Ausfuehrung "noch offen" zeigte:
drei Leerdarstellungen in einer Kachel (04.09.2026). */}
<Wertzeile label="Erstzulassung" wert={datum(fahrzeug.erstzulassung)} />
<Wertzeile label="Ausführung" wert={ausfuehrung || "noch offen"} />
<Wertzeile label="Ausstattung" wert={`${positionen} Positionen`} />
</>
+2 -9
View File
@@ -17,9 +17,8 @@ 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 } from "./bausteine"
import { KRAFTSTOFFSORTEN, Leerzustand, Markenlogo, Werteliste, Wertzeile } from "./bausteine"
import { LOESCH_HINWEIS, LOESCH_TEXT, bestaetigen } from "./bestaetigung"
import { markenlogoUrl } from "./bilder"
import { BelegPopup } from "./BelegPopup"
import { TankstellenKarte } from "./TankstellenKarte"
@@ -116,13 +115,7 @@ export function TankDetail({ id, geheZu }: { id: string | undefined; geheZu: (na
<Wertzeile
label={
<span className="dm-marke">
<img
src={markenlogoUrl()}
alt=""
onError={(e) => {
e.currentTarget.style.display = "none"
}}
/>
<Markenlogo />
SmartDeal-Ersparnis
</span>
}
+2 -7
View File
@@ -21,9 +21,8 @@ import {
geteiltenBelegSetzen,
geteiltenBelegStand,
} from "../daten/geteilterBeleg"
import { Blattzeile, KRAFTSTOFFSORTEN, Leerzustand } from "./bausteine"
import { Blattzeile, KRAFTSTOFFSORTEN, Leerzustand, Markenlogo } from "./bausteine"
import { BelegPopup } from "./BelegPopup"
import { markenlogoUrl } from "./bilder"
import { belegFormularwerte, type BelegEntwurf } from "../daten/belegentwurf"
import { tankstelleKurz } from "../daten/tankstelle"
import { LOESCH_HINWEIS } from "./bestaetigung"
@@ -75,14 +74,10 @@ export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
</span>
{smartdealAktiv && (
<Pill>
<img
src={markenlogoUrl()}
<Markenlogo
alt={einstellungen?.markenname ?? "Logo"}
className="dm-markenlogo"
style={{ marginRight: 6 }}
onError={(e) => {
e.currentTarget.style.display = "none"
}}
/>
{eur(ersparnisJahr)}
</Pill>
+10 -2
View File
@@ -39,6 +39,7 @@ export function Versicherung({ geheZu }: { geheZu: (name: SeitenName, id?: strin
const v = fahrzeug.versicherung
const teile = (v["teile"] ?? []) as Beitragsposten[]
const beitrag = (v["beitrag"] as number) ?? 0
const steuersatz = ((v["steuersatz"] as string) ?? "").trim()
const selbst = (v["selbst"] ?? []) as [string, string][]
const service = (v["service"] as string) ?? ""
const notfall = (v["notfall"] as string) ?? ""
@@ -67,7 +68,9 @@ export function Versicherung({ geheZu }: { geheZu: (name: SeitenName, id?: strin
</div>
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
{(v["zahlweise"] as string) ?? ""} · enthält {eur((v["steueranteil"] as number) ?? 0)}
Versicherungsteuer ({(v["steuersatz"] as string) ?? ""})
{/* Ohne Steuersatz KEINE leeren Klammern - bis zum 04.09.2026
stand dort ein nacktes Klammerpaar. */}
Versicherungsteuer{steuersatz ? ` (${steuersatz})` : ""}
</span>
<Werteliste
kinder={teile.map((t, i) => (
@@ -411,7 +414,12 @@ export function Vertragsdetails({ geheZu }: { geheZu: (name: SeitenName, id?: st
if (!fahrzeug) return null
const v = fahrzeug.versicherung
const leistungen = (v["leistungen"] ?? []) as Leistungsgruppe[]
/* Gruppen ohne Namen UND ohne Posten fallen raus: sie ergaben eine leere
graue Kachel (am 04.09.2026 auf der Musterseite gesehen). Eine Kachel
ohne Inhalt sagt nichts und sieht aus wie ein Ladefehler. */
const leistungen = ((v["leistungen"] ?? []) as Leistungsgruppe[]).filter(
(g) => (g.gruppe ?? "").trim() || (g.posten ?? []).length,
)
return (
<>
+92 -7
View File
@@ -3,7 +3,13 @@
* gebraucht wird, aber zu speziell für das Design-System ist.
*/
import { useRef, useState, type ReactNode, type TouchEvent as ReactTouchEvent } from "react"
import {
useRef,
useState,
type CSSProperties,
type ReactNode,
type TouchEvent as ReactTouchEvent,
} from "react"
import { ActionButton, ActionSheet, Tile } from "@audi-dash/ui"
@@ -11,7 +17,7 @@ import type { SicherheitsPunkt } from "../api"
import { useDaten } from "../daten/DatenKontext"
import { SymbolZahnradVoll } from "../symbole"
import { Bild } from "./Bild"
import { bildAlsBase64, bildVerkleinern, bildVersionErneuern } from "./bilder"
import { bildAlsBase64, bildVerkleinern, bildVersionErneuern, markenlogoUrl } from "./bilder"
import { LOESCH_HINWEIS, LOESCH_TEXT, bestaetigen } from "./bestaetigung"
/** Kraftstoffsorten zur Auswahl - dieselbe feste Liste wie `KRAFTSTOFFSORTEN`
@@ -350,6 +356,16 @@ export function StatusKreis({ ok }: { ok: boolean | null }) {
*/
const WISCH_BREITE = 88
const WISCH_START = 10
/* Ab diesem Anteil der Zeilenbreite loescht das Loslassen sofort, ohne
zweiten Tipp - so macht es iOS, und genau das fehlte hier: ein voller
Wisch blieb wie ein kurzer an der Raste stehen.
0,55 haelt die Geste absichtsvoll. Eine halbe Zeile ist beim seitlichen
Scrollen leicht versehentlich erreicht, eine klare Mehrheit nicht.
Wortgleich mit FULL_SWIPE_FRACTION in SwipeRow (Design-System). */
const WISCH_VOLL_ANTEIL = 0.55
const WISCH_VOLL_MAX = 220
const WISCH_VOLL_MIN = 120
export function WischLoeschen({
beiLoeschen,
@@ -362,11 +378,27 @@ export function WischLoeschen({
children: ReactNode
}) {
const [offen, setzeOffen] = useState(false)
const [zug, setzeZug] = useState<number | null>(null)
/* Der Zug liegt zusaetzlich in einer Referenz, und die entscheidet.
Der Zustand hinkt eine Darstellung hinterher: treffen die letzte
Bewegung und das Loslassen in derselben Aufgabe ein - genau das tut
ein schneller Wisch -, laese `beiEnde` noch `null` und die Geste waere
verloren. Am 04.09.2026 gemessen: ein voller Wisch tat gar nichts. */
const [zug, setzeZugZustand] = useState<number | null>(null)
const zugRef = useRef<number | null>(null)
const setzeZug = (wert: number | null) => {
zugRef.current = wert
setzeZugZustand(wert)
}
const start = useRef<{ x: number; y: number } | null>(null)
const entschieden = useRef(false)
const rahmen = useRef<HTMLDivElement | null>(null)
const versatz = zug ?? (offen ? -WISCH_BREITE : 0)
const vollAb = () => {
const breite = rahmen.current?.offsetWidth ?? 0
return -Math.min(WISCH_VOLL_MAX, Math.max(WISCH_VOLL_MIN, breite * WISCH_VOLL_ANTEIL))
}
const scharf = versatz <= vollAb()
const beiStart = (e: ReactTouchEvent<HTMLDivElement>) => {
if (e.touches.length !== 1) return
@@ -389,13 +421,24 @@ export function WischLoeschen({
entschieden.current = true
}
const basis = offen ? -WISCH_BREITE : 0
setzeZug(Math.max(-WISCH_BREITE, Math.min(0, basis + dx)))
// Nicht mehr bei WISCH_BREITE abschneiden: ohne den weiteren Weg gibt
// es keinen vollen Wisch und keine Rueckmeldung, dass er loescht.
setzeZug(Math.min(0, basis + dx))
}
const beiEnde = () => {
if (zug !== null) setzeOffen(zug < -WISCH_BREITE / 2)
const versatzJetzt = zugRef.current
start.current = null
setzeZug(null)
if (versatzJetzt === null) return
if (versatzJetzt <= vollAb()) {
// Ganz durchgezogen: jetzt handeln. `beiLoeschen` ist der Handler des
// Aufrufers und fragt dort nach, wo die Daten nicht wiederkommen.
setzeOffen(false)
beiLoeschen()
return
}
setzeOffen(versatzJetzt < -WISCH_BREITE / 2)
}
// Sichtbar erst, wenn wirklich gewischt wird. Ohne das liegt der rote
@@ -406,10 +449,15 @@ export function WischLoeschen({
const zeigt = offen || zug !== null
return (
<div className={"dm-wisch" + (zeigt ? " dm-wisch--zeigt" : "")}>
<div className={"dm-wisch" + (zeigt ? " dm-wisch--zeigt" : "")} ref={rahmen}>
<button
type="button"
className="dm-wisch__loeschen"
// Die Flaeche waechst mit dem Zug - je weiter gezogen, desto mehr
// Rot. Dieselbe Lesart wie auf iOS: Farbe heisst entschieden.
style={{ width: Math.max(WISCH_BREITE, -versatz) }}
className={
"dm-wisch__loeschen" + (scharf ? " dm-wisch__loeschen--scharf" : "")
}
tabIndex={offen ? 0 : -1}
aria-hidden={!offen}
onClick={() => {
@@ -452,3 +500,40 @@ export function gruppenErgebnis(punkte: readonly SicherheitsPunkt[]): boolean |
if (punkte.length === 0 || punkte.some((p) => p.ok === null)) return null
return punkte.every((p) => p.ok === true)
}
/**
* Markenlogo für die SmartDeal-Anzeige.
*
* RENDERT NICHTS, WENN ES KEINE ADRESSE GIBT. Ohne gespeicherten Zugang gibt
* `markenlogoUrl()` `undefined` zurück; React lässt `src` dann weg — und ein
* `<img>` ohne `src` zeigt das **kaputte Bildsymbol**. Der `onError`-Rettungs-
* anker greift dabei nicht: ohne Adresse findet gar kein Ladeversuch statt,
* also gibt es auch kein Fehlerereignis. Am 04.09.2026 auf der Musterseite
* gesehen, samt Alternativtext „Shell" neben dem kaputten Symbol.
*
* `onError` bleibt trotzdem stehen — für den anderen Fall: die Adresse ist da,
* die Datei fehlt auf dem Server.
*/
export function Markenlogo({
alt = "",
className,
style,
}: {
alt?: string
className?: string
style?: CSSProperties
}) {
const url = markenlogoUrl()
if (!url) return null
return (
<img
src={url}
alt={alt}
{...(className ? { className } : {})}
{...(style ? { style } : {})}
onError={(e) => {
e.currentTarget.style.display = "none"
}}
/>
)
}
+15 -1
View File
@@ -167,7 +167,21 @@ export function bildUrl(dateiname: string | null | undefined): string | undefine
if (!dateiname) return undefined
const basis = zugangLesenSynchron()?.basisUrl
if (!basis) return undefined
return `${basis}/local/bilder/${dateiname}?v=${bildStand(dateiname)}`
const stand = bildStand(dateiname)
/* Stand 0 heisst: dieses Foto gibt es nicht (siehe bildStand). Dann gar
keine Adresse - der Platzhalter steht ohnehin bereit.
Bis zum 04.09.2026 wurde die Adresse trotzdem gebaut, und der Browser
holte bei JEDEM Rendern eine 404. Live gemessen: fuenf Anfragen fuer
front-schraeg.webp in einem einzigen Ladevorgang, dazu heckansicht,
cockpit, sitze und seitenansicht-winter. Die App weiss laengst, dass
es sie nicht gibt; sie muss nicht danach fragen.
Nur wenn das Backend gar keine Staende mitliefert (bildStaende null,
Rueckfall auf bildVersion), wird weiter probiert - dort ist 0 kein
moeglicher Wert und die Abwesenheit unbekannt. */
if (bildStaende && stand === 0) return undefined
return `${basis}/local/bilder/${dateiname}?v=${stand}`
}
/**
+5
View File
@@ -18,6 +18,11 @@ body {
}
body {
/* Die Schrift steht sonst nur auf .ads-root. Alles ausserhalb - und sei
es nur eine Fehlermeldung des Browsers - fiel damit auf die
Vorgabeschrift zurueck, am 04.09.2026 als Times New Roman gemessen.
Sichtbar war heute nichts; die Flanke bleibt trotzdem offen. */
font-family: var(--font-stack, Helvetica, Arial, sans-serif);
background: var(--canvas);
}
+28
View File
@@ -350,3 +350,31 @@
gap: 10px;
flex-wrap: wrap;
}
/* Apple HIG: 44x44 pt als kleinstes Tippziel. Der Pfeil SIEHT weiterhin
34x34 aus - die Trefferflaeche liegt unsichtbar darum.
Warum ein Overlay statt groesserer Masse: die Kopfzeile ist ueber viele
Sitzungen am gerenderten Bild abgestimmt worden, und der Titel steht nur
deshalb wirklich mittig, weil links und rechts genau 34px liegen (siehe
.dm-kopf__aktion daneben). Groessere Knoepfe wuerden das verschieben.
Am 04.09.2026 gemessen: 34x34 ohne jede Vergroesserung - beim
meistbenutzten Bedienelement der App. Wortgleich mit .ads-icon-button im
Design-System und #back im Panel. */
.dm-kopf__zurueck,
.dm-kopf__aktion {
position: relative;
}
.dm-kopf__zurueck::after,
.dm-kopf__aktion::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 44px;
height: 44px;
border-radius: var(--r-pill);
}
+68 -2
View File
@@ -158,7 +158,11 @@ input[type="date"].dm-eingabe {
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
/* Seitlich 20 statt 32: auf 375 px blieben fuer die Beschriftung nur
87 px, und "Server-Adresse" brach dadurch am Bindestrich um -
ausgerechnet auf dem allerersten Bildschirm (gemessen 04.09.2026).
Oben und unten bleibt es bei 32. */
padding: 32px 20px;
overflow-y: auto;
}
@@ -397,7 +401,7 @@ input[type="date"].dm-eingabe {
visibility: hidden;
border: 0;
padding: 0;
background: var(--bad);
background: var(--bad-flaeche, var(--bad));
color: #fff;
font: inherit;
font-size: 15px;
@@ -408,6 +412,13 @@ input[type="date"].dm-eingabe {
visibility: visible;
}
/* Voll durchgewischt: die Flaeche wird kraeftiger, damit vor dem Loslassen
ablesbar ist, dass jetzt geloescht wird und nicht nur aufgeklappt.
Wortgleich mit .ads-swipe-delete--armed im Design-System. */
.dm-wisch__loeschen--scharf {
filter: brightness(1.12);
}
.dm-wisch__inhalt {
position: relative;
/* Deckend, NICHT --tile: die Zeile liegt auf einer Kachel, und die ist
@@ -2176,3 +2187,58 @@ button.dm-listenzeile {
.dm-debugknopf:active {
color: var(--fg2);
}
/* --- Apple HIG: 44x44 pt als kleinstes Tippziel -------------------------
Am 04.09.2026 gemessen und hier nachgezogen. Zwei Wege, je nach Element:
(a) Unsichtbares Overlay, wo die sichtbare Groesse Teil des Bildes ist -
der Griff des Standort-Blatts (36x4) und die Zahnrad-Knoepfe.
(b) Echte Mindesthoehe, wo das Element ohnehin eine Flaeche ist und 44px
nicht stoeren - Eingabefelder, Auswahlfelder, Pillen, Segmente.
(b) veraendert das Bild sichtbar: Formulare werden etwas hoeher. Das ist
Absicht - 36px hohe Eingabefelder sind auf dem Telefon zu knapp. */
.dm-standortmenu__griff {
position: relative;
}
.dm-standortmenu__griff::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 88px;
height: 44px;
}
.dm-standort-pille {
min-height: 44px;
}
.dm-montiert {
min-height: 44px;
}
.ads-feld input,
.ads-feld select,
input.dm-eingabe,
select.dm-auswahl {
min-height: 44px;
}
/* Das Druckfeld ist schmal (58px), aber die Hoehe gilt auch dort. */
.ads-feld input.druckfeld {
min-height: 44px;
}
.ads-seg button {
min-height: 44px;
}
/* Der Schieberegler traegt seinen Daumen selbst; die Bahn ist duenn, das
Element darum darf trotzdem 44px hoch treffen. */
input[type="range"] {
min-height: 44px;
}