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
+203
View File
@@ -10914,3 +10914,206 @@ Parser-Suite jetzt **17 Tests** (von 11): die neue Klasse
und dass ein Aufgeben den Beleg nicht beschaedigt. Alle zwoelf Belege liefern
weiterhin `Shell`. `audi_ha_test` auf 2026.9.4.22, 0 Tracebacks, das Nachtragen
lief erwartungsgemaess **nicht** erneut.
## CP. Der grosse Pruefdurchgang - und was er gefunden hat (2026.9.4.23)
Auftrag des Eigentuemers: alle Funktionen, alle Bildschirme, optisch und
funktional, Audi CI und Apple HIG, danach die Paritaet. Danach: alles beheben.
### Wie geprueft wurde
Nicht nach Augenmass. Eine Pruefroutine (`/local/pruef.js` im Testcontainer)
lief auf **jedem der 26 Bildschirme** und mass seitlichen Ueberlauf,
Tippzielgroessen, abgeschnittenen Text, Schriftgroessen unter 11 px, Bilder
ohne Quelle und **WCAG-Kontrast fuer jeden einzelnen Textknoten**. Dazu die
Live-App mit den echten Daten (der Eigentuemer hat den Zugang selbst
eingetragen), die Musterseite fuer Leer- und Fehlerzustaende, und das Panel
zum Abgleich.
**Merkposten fuer kuenftige Pruefungen:** ohne Token zeigt die App nur den
Einrichtungsbildschirm. Statt wieder auf die Musterseite auszuweichen und die
Live-Pruefung als "nicht moeglich" zu vermerken - **danach fragen**. Der
Eigentuemer hat sie sofort gegeben.
### Befund 1: deutsche Datumsangaben wurden falsch gelesen
`new Date("01.03.2025")` liest amerikanisch, also Monat vor Tag:
| gespeichert | angezeigt |
|---|---|
| `01.03.2025` (1. Maerz) | **`03.01.2025`** |
| `12.11.2024` | **`11.12.2024`** |
| `25.03.2025` | **"-"** - der Wert verschwand |
| `08/2026` (Hauptuntersuchung) | **"-"** |
Tage bis 12 wurden **still vertauscht**, ab Tag 13 fiel der Wert ganz weg.
Sichtbar an der Erstzulassung: "Mein Audi" zeigte den Rohwert richtig,
"Identitaet und Technik" formatierte ihn und drehte ihn dabei um - **derselbe
Wert, zwei Ergebnisse auf zwei Bildschirmen**.
`alsZeitpunkt()` in `format.ts` erkennt jetzt genau die drei Formen, die in
diesem Projekt vorkommen: ISO vom Backend, `TT.MM.JJJJ` aus den
Eingabefeldern, `MM/JJJJ` fuer die Hauptuntersuchung. Alles andere bleibt
`null` - **lieber kein Datum als ein erfundenes**. Unmoegliche Daten werden
abgewiesen statt still verschoben (`new Date(2025,1,31)` waere der 3. Maerz).
**Monatsgenau ist eine eigene Genauigkeit.** `08/2026` nennt keinen Tag;
daraus "01.08.2026" zu machen waere eine Erfindung. Das Ergebnis traegt seine
Genauigkeit mit sich, und angezeigt wird "August 2026".
### Befund 2: die Hauptuntersuchung war in beiden Oberflaechen unerreichbar
`08/2026` stand im Profil, gezeigt wurde "-". In der App aus Befund 1. Im
Panel aus einem anderen Grund, der genauso stillschweigend war: es leitete die
Faelligkeit aus der **Erstzulassung** ab - aber nur, wenn die ein Schraegstrich
enthielt. Bei `01.03.2025` (eine voellig richtige Eingabe) blieb der Termin
leer, und den ausdruecklich eingetragenen Wert benutzte es gar nicht.
Beide Oberflaechen nehmen jetzt dieselbe Reihenfolge: **eingetragene
Faelligkeit vor Servicebuch vor Erstzulassung + 24 Monate**. Das Panel hat dazu
`datumAusText()` bekommen - wortgleich mit `alsZeitpunkt()` in der App.
### Befund 3: die Tankstellenmarke stand zweimal da
Mein eigener Fehler vom selben Tag (Abschnitt CN). Ein von Hand eingetragener
Name ohne Komma rutschte komplett als "Ort" durch:
"Shell München Ost" + "Musterstraße 5"
-> "Shell, Musterstr., Shell München Ost"
Die Regel uebersprang nur Teile, die **fuer sich** eine Marke sind.
`ohneMarkeVorn()` nimmt die Marke jetzt vorn ab. Steht sie NICHT am Anfang
("Autohof Shell Nord"), bleibt der Teil unangetastet - dort ist nicht sicher,
was Marke und was Ortsname ist.
### Befund 4: Bilder wurden geholt, obwohl beide wussten, dass es sie nicht gibt
Der Code sagt es selbst - "0 heisst: dieses Foto gibt es nicht" - und baute
die Adresse trotzdem. Gemessen: **fuenf Anfragen fuer `front-schraeg.webp` in
einem einzigen Ladevorgang**, dazu heckansicht, cockpit, sitze und
seitenansicht-winter, jede eine 404, bei jedem Rendern erneut.
Sichtbar war nichts (der Platzhalter griff ueber `onerror`), die Konsole aber
voll. Jetzt liefern `bildUrl()` bzw. `bildAdresse()` gar keine Adresse mehr,
wenn der Stand 0 ist. **Nach der Korrektur gemessen: 4 Anfragen, alle 200,
kein einziges `v=0`.**
### Befund 5: ein Bildelement ohne Quelle zeigt das kaputte Bildsymbol
Und der Rettungsanker greift dabei nicht: **ohne `src` findet kein Ladeversuch
statt, also feuert `onerror` nie**. Genau dann, wenn er gebraucht wuerde. Auf
der Musterseite als kaputtes Symbol samt Alternativtext "Shell" gesehen.
Neu `Markenlogo` (App) - rendert bei fehlender Adresse **nichts**. Im Panel
faellt das `src`-Attribut weg und die Box startet gleich als `bild-fehlt`; das
Bildelement bleibt trotzdem stehen, weil die Galerie es weiterschaltet.
### Kleinere Maengel, alle behoben
* `Versicherungsteuer ()` - leere Klammern ohne Steuersatz (beide).
* Eine Leistungsgruppe ohne Namen UND ohne Posten wurde zur **leeren grauen
Kachel** (beide).
* "Server-Adresse" brach am Bindestrich um - auf dem allerersten Bildschirm.
Seitlicher Rand von 32 auf 20 px.
* Drei Leerdarstellungen in EINER Kachel (Strich, "noch offen", nichts). Die
Erstzulassung wird jetzt formatiert und zeigt einen Strich, wenn nichts da
ist.
* Das Wurzelelement hatte keine Schriftfamilie - Rueckfall auf Times New
Roman. Sichtbar war heute nichts, die Flanke ist trotzdem zu.
### Apple HIG
**Kontrast**: auf 25 von 26 Bildschirmen einwandfrei. Eine Unterschreitung -
"Tankvorgang loeschen", `#FF3B30` auf `#F2F2F7` = **3,18** statt 4,5.
Dabei kam ein zweites, groesseres heraus: **weisse Schrift auf der roten
Loeschflaeche** ergibt 3,41 (Nacht) und 3,55 (Tag) - beide zu wenig.
Geloest ueber eine **Trennung der Rollen**: Rot als Schrift und Rot als Flaeche
brauchen Gegensaetzliches. Rote Schrift will Abstand zum HELLEN Grund, eine
rote Flaeche will Abstand zur WEISSEN Schrift darauf. Hell ist `--bad` jetzt
`#D70015` (4,83), und `--bad-flaeche` ist in beiden Modi `#D70015` (weiss
darauf: 5,38). Beides Apples eigenes systemRed fuer erhoehten Kontrast - die
HIG-Palette bleibt.
**Tippziele**: durchgehend unter 44x44 pt - der Zurueck-Pfeil mit 34x34, und
**ohne jede Vergroesserung**, beim meistbenutzten Element der App.
Zwei Wege, je nach Element:
* **Unsichtbares Overlay**, wo die sichtbare Groesse Teil des Bildes ist
(Zurueck-Pfeil, Symbolknoepfe, Griff des Standort-Blatts). Nachgemessen mit
`elementFromPoint`: **sichtbar 34x34, treffbar 44x44**.
* **Echte Mindesthoehe** 44 px, wo das Element ohnehin Flaeche ist
(Eingabefelder, Auswahlfelder, Pillen, Segmente).
Der zweite Weg **veraendert das Bild sichtbar**: Formulare werden hoeher. Das
ist Absicht und dem Eigentuemer so gemeldet.
### Wischen in der Bildergalerie (Wunsch des Eigentuemers)
Links/rechts wischen schaltet durch, in beide Richtungen. Zeigerereignisse
statt Beruehrungsereignisse - dieselbe Verdrahtung traegt Finger, Stift und
Maus, genau wie das Standort-Blatt es schon macht. Ueberwiegt die senkrechte
Bewegung, war es ein Scrollen. Ein Wisch schluckt den nachlaufenden Klick,
sonst schaltete die Galerie zweimal weiter.
### Loeschgeste HIG-konform (Wunsch des Eigentuemers)
Ein **voller** Wisch loescht jetzt sofort, ohne zweiten Tipp. Schwelle: 55 %
der Zeilenbreite (mindestens 120, hoechstens 220 px) - eine halbe Zeile ist
beim seitlichen Scrollen leicht versehentlich erreicht, eine klare Mehrheit
nicht. Die rote Flaeche waechst mit dem Zug und wird beim Ueberschreiten
heller: **Farbe heisst entschieden**. Die Rueckfrage bleibt - geloescht werden
Fahrten, Belege und Messwerte, die nicht wiederkommen.
**Dabei einen echten Fehler gefunden:** die Abschlussbehandlung las den
Zugwert aus dem React-Zustand, und der hinkt eine Darstellung hinterher.
Treffen die letzte Bewegung und das Loslassen in derselben Aufgabe ein - genau
das tut ein schneller Wisch -, war die Geste verloren. Nachgestellt: **ein
voller Wisch tat gar nichts.** Der Zug liegt jetzt zusaetzlich in einer
Referenz, und die entscheidet.
### Paritaet
Alle 26 Routen und Titel decken sich; der Fahrzeugstatus ist **zeilengleich**;
`live` bleibt der einzige app-eigene Bildschirm.
**Zwei ungeplante Abweichungen gefunden, beide geschlossen:** die
Datumsformatierung (App formatierte, Panel gab roh aus - deshalb war das Panel
richtig und die App falsch) und die Herkunft der Hauptuntersuchung. Beide
zeigen jetzt dasselbe.
Die Tankstellen-Funktionen weiterhin **byteweise gleich** ueber elf Faelle -
die Panel-Funktionen aus dem Buendel geschnitten und in Node gegen dieselben
Eingaben laufen lassen.
### Verifiziert
`tsc --noEmit` sauber, **271 Tests** (von 258: 9 fuer die Datumsformen, 4 fuer
die Marke am Wortanfang), `vite build` sauber, Design-System gebaut, Panel als
**Modul** geparst, `audi_ha_test` auf `2026.9.4.23`, **0 Tracebacks**.
**Live nachgemessen, nicht nur kompiliert:**
* Erstzulassung `01.03.2025` auf BEIDEN Bildschirmen, Hauptuntersuchung
`August 2026` - in App **und** Panel.
* Bildanfragen: 4, alle 200, kein `v=0`.
* Zurueck-Pfeil: sichtbar 34x34, treffbar 44x44. Eingabefelder 44 px hoch.
* Galerie: Wisch links vor, Wisch rechts zurueck, kurzer Tipp vor.
* Loeschzeile: kurzer Wisch klappt auf, voller Wisch (-260 bei 343 px Breite)
loest die Rueckfrage "Fahrt vom 10.08.2026 loeschen?" aus.
* Kontrastpruefung ueber alle Bildschirme: **keine Unterschreitung mehr**.
### Nicht geprueft - und warum
Live-Fahrt (braucht eine laufende Fahrt), QR-Scanner (Kamera in der Vorschau
gesperrt), Zwischenablage/Teilen/Kalender (nativ, brauchen das Geraet), das
echte iPhone (Notch, Dynamic Type), und Loeschvorgaenge wurden nicht
ausgeloest - das sind die Daten des Eigentuemers.
**Eine Korrektur an mir selbst:** ich hatte zwischendurch gemeldet, das
Standort-Blatt sei fehlerhaft, weil "Route" und "Teilen" hinter der Tab-Leiste
liegen. Das war falsch - die 96-px-Vorschau ist Absicht, aufgezogen sitzt das
Blatt sauber 30 px ueber der Leiste. Mein Klick-Test war der Fehler, nicht die
App: das Blatt hoert auf Zeigerereignisse, nicht auf `click`.
@@ -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;
}
@@ -1 +1 @@
{"version":"2026.9.4.22","sha256":"66406934fb50aa810ff782ce877bc82d25ec8d338979d5ae9e28e5dc5c2ea521","bytes":323041,"gebaut":"2026-09-04T15:08:22Z"}
{"version":"2026.9.4.23","sha256":"7cc61f10ec846824ae34bc033115c795ab9026e7d56a72cdf9ec4cb6032e8559","bytes":324157,"gebaut":"2026-09-04T16:07:49Z"}
@@ -389,8 +389,17 @@ function bildStand(datei) {
// loest sich damit auf.
return BILD_STAENDE[datei] ?? 0;
}
/* null, wenn das Backend meldet, dass es dieses Foto nicht gibt (Stand 0).
Vorher wurde die Adresse trotzdem gebaut und der Browser holte bei JEDEM
Rendern eine 404 - am 04.09.2026 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. */
function bildAdresse(datei) {
return `/local/bilder/${datei}?v=${bildStand(datei)}`;
const stand = bildStand(datei);
if (BILD_STAENDE && stand === 0) return null;
return `/local/bilder/${datei}?v=${stand}`;
}
/* Platzhalter statt kaputtem Bild-Symbol: bei Ladefehler (Bilder aus §7a
@@ -406,8 +415,15 @@ function bildAdresse(datei) {
eines Fotos nicht. */
function bildMitPlatzhalter(src, datei, label, klasse, imgId, dateiZeigen = false) {
const idTeil = imgId ? ` id="${imgId}"` : "";
return `<div class="bildbox ${klasse}" data-bildbox title="${esc(label)}">
<img class="${klasse}"${idTeil} src="${src}" alt="${esc(label)}" data-bildlabel="${esc(label)}" data-bilddatei="${esc(datei)}"
// Ohne Adresse KEIN src-Attribut - ein leeres src holt die Seite selbst
// erneut, und ein <img> ganz ohne src zeigt das kaputte Bildsymbol.
// Sichtbar bleibt es nicht: .bildbox.bild-fehlt img ist ausgeblendet.
// Das <img> selbst bleibt trotzdem stehen, weil die Galerie es weiter-
// schaltet (#carimg) - ohne das Element bliebe sie stehen.
const srcTeil = src ? ` src="${src}"` : "";
const fehltTeil = src ? "" : " bild-fehlt";
return `<div class="bildbox ${klasse}${fehltTeil}" data-bildbox title="${esc(label)}">
<img class="${klasse}"${idTeil}${srcTeil} alt="${esc(label)}" data-bildlabel="${esc(label)}" data-bilddatei="${esc(datei)}"
onerror="this.closest('[data-bildbox]').classList.add('bild-fehlt')">
<div class="platzhalter ${klasse}">
<svg viewBox="0 0 24 24" class="platzhalter-icon" fill="currentColor"><path fill-rule="evenodd" d="m23.35 1.35-22 22-.7-.7 22-22zM1 3h17.5v1H2v16H1zm21 11.85V4h1v17H6v-1h16v-3.77l-3.24-3.08-4.57 4.48-2.85-2.84.7-.7 2.15 2.14 4.56-4.47 3.25 3.1ZM4 8.5a2.5 2.5 0 1 1 5 0 2.5 2.5 0 0 1-5 0M6.5 7a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3" clip-rule="evenodd"/></svg>
@@ -602,20 +618,29 @@ function markenlogo() {
szeneKlasse(), das je nach Bild und Reifensatz "strasse" oder "winter"
an .szene haengte; die zugehoerigen ::after-Regeln sind in
audi-dashboard.css beschrieben. */
function bildWeiter() {
/* Ein Bild weiter (richtung 1) oder zurueck (-1).
Die Richtung kam am 04.09.2026 dazu: der Eigentuemer wollte die Galerie
auch durch Wischen nach links/rechts durchschalten, nicht nur durch
Tippen (galerieWischenVerdrahten unten, wortgleich mit der App). */
function bildWeiter(richtung = 1) {
// Ueber die Galerie-Plaetze, nicht ueber alle: die Draufsicht wird
// uebersprungen (siehe nurStatus in BILDER).
const jetzt = GALERIE.findIndex((b) => b === BILDER[bild]);
const naechst = GALERIE[(Math.max(0, jetzt) + 1) % GALERIE.length];
const anzahl = GALERIE.length;
if (!anzahl) return;
const naechst = GALERIE[(Math.max(0, jetzt) + richtung + anzahl) % anzahl];
bild = BILDER.indexOf(naechst);
const img = ROOT.getElementById("carimg");
if (!img) return;
const info = bildInfo(bild);
img.src = info.src;
// Ohne Adresse das Attribut ENTFERNEN statt auf null zu setzen: src=""
// laesst den Browser die Seite selbst erneut holen.
if (info.src) img.src = info.src; else img.removeAttribute("src");
img.alt = info.label;
const box = img.closest("[data-bildbox]");
if (box) {
box.classList.remove("bild-fehlt"); // neues Bild kann laden, auch wenn das vorherige fehlte
box.classList.toggle("bild-fehlt", !info.src);
box.querySelector(".platzhalter-label").textContent = info.label;
}
const sz = ROOT.getElementById("szene");
@@ -623,6 +648,34 @@ function bildWeiter() {
[...ROOT.getElementById("dots").children].forEach((d, i) => d.classList.toggle("on", GALERIE[i] === BILDER[bild]));
}
/* 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
(standortMenuVerdrahten). Ein Wisch ab 40 px zaehlt als Wisch, alles
darunter bleibt ein Tipp - dieselbe Schwelle wie dort.
GEWISCHT SCHLUCKT DEN KLICK: ein Wisch loest anschliessend auch ein
click aus. Ohne diese Sperre schaltete die Galerie zweimal weiter. */
let galerieZug = null;
let galerieGewischt = false;
function galerieWischenVerdrahten() {
ROOT.addEventListener("pointerdown", (e) => {
const feld = e.target.closest && e.target.closest("[data-bildgalerie]");
galerieZug = feld ? { x0: e.clientX, y0: e.clientY } : null;
});
ROOT.addEventListener("pointerup", (e) => {
const zug = galerieZug;
galerieZug = null;
if (!zug) return;
const dx = e.clientX - zug.x0;
// Senkrecht ueberwiegt: das war ein Scrollen, kein Blaettern.
if (Math.abs(dx) <= 40 || Math.abs(e.clientY - zug.y0) > Math.abs(dx)) return;
galerieGewischt = true;
bildWeiter(dx < 0 ? 1 : -1);
});
}
/* ---------------------------------------------------- Termine/Vorhersage
heute() statt einer eingefrorenen Konstante - der Prototyp nutzte ein
festes Demo-Datum (new Date(2026,7,3)), das für den echten Betrieb durch
@@ -637,7 +690,72 @@ const gueltigesDatum = (d) => d instanceof Date && !Number.isNaN(d.getTime());
const dedat = (d) => (gueltigesDatum(d)
? d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" })
: "");
function dat(s) { const [d, m, j] = s.split(".").map(Number); return new Date(j, m - 1, d); }
function dat(s) {
// Nimmt seit dem 04.09.2026 auch ein fertiges Date entgegen: die
// Hauptuntersuchung reicht ihre Basis als Date herein (siehe
// datumAusText/hauptuntersuchungsTermin), monatePlus() ruft dat() darauf.
if (s instanceof Date) return new Date(s.getTime());
const [d, m, j] = String(s).split(".").map(Number);
return new Date(j, m - 1, d);
}
/* Freitext-Datum aus dem Profil - WORTGLEICH mit alsZeitpunkt() in
companion-app/src/format.ts.
Die Felder "Erstzulassung" und "Hauptuntersuchung" sind Eingabefelder, und
der Eigentuemer traegt dort ein, was ihm richtig scheint. Am 04.09.2026
gemessen: das Panel leitete die Hauptuntersuchung nur dann aus der
Erstzulassung ab, wenn diese ein "/" enthielt - bei "01.03.2025" (eine
voellig richtige Eingabe) blieb der Termin still leer.
`genau: "monat"` heisst: die Angabe nennt keinen Tag ("08/2026"). Daraus
den Ersten des Monats zu ZEIGEN waere eine Erfindung - deshalb die
Genauigkeit mitfuehren und in datumText() beruecksichtigen. */
const DATUM_ISO = /^(\d{4})-(\d{1,2})-(\d{1,2})$/;
const DATUM_TAG = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/;
const DATUM_MONAT = /^(\d{1,2})[./](\d{4})$/;
const DATUM_ISO_MONAT = /^(\d{4})-(\d{1,2})$/;
function datumBauen(jahr, monat, tag) {
if (monat < 1 || monat > 12 || tag < 1 || tag > 31) return null;
const d = new Date(jahr, monat - 1, tag);
// new Date(2025,1,31) waere der 3. Maerz - ein unmoegliches Datum soll "-"
// ergeben, nicht klammheimlich ein anderes.
if (d.getFullYear() !== jahr || d.getMonth() !== monat - 1 || d.getDate() !== tag) return null;
return d;
}
function datumAusText(wert) {
if (wert instanceof Date) return gueltigesDatum(wert) ? { zeit: wert, genau: "tag" } : null;
const text = String(wert == null ? "" : wert).trim();
if (!text) return null;
let m = DATUM_ISO.exec(text);
if (m) { const d = datumBauen(+m[1], +m[2], +m[3]); return d ? { zeit: d, genau: "tag" } : null; }
m = DATUM_TAG.exec(text);
if (m) { const d = datumBauen(+m[3], +m[2], +m[1]); return d ? { zeit: d, genau: "tag" } : null; }
m = DATUM_MONAT.exec(text);
if (m) { const d = datumBauen(+m[2], +m[1], 1); return d ? { zeit: d, genau: "monat" } : null; }
m = DATUM_ISO_MONAT.exec(text);
if (m) { const d = datumBauen(+m[1], +m[2], 1); return d ? { zeit: d, genau: "monat" } : null; }
return null;
}
/* Zeigt eine monatsgenaue Angabe als Monat statt als erfundenen Ersten. */
const demonat = (d) => (gueltigesDatum(d)
? d.toLocaleDateString("de-DE", { month: "long", year: "numeric" })
: "");
const datumText = (d, genau) => (genau === "monat" ? demonat(d) : dedat(d));
/* Die Erstzulassung, wie sie angezeigt wird: formatiert, wenn sie sich lesen
laesst, sonst der Rohwert, sonst ein Strich.
Bis zum 04.09.2026 stand hier immer der Rohwert und bei leerem Feld gar
nichts - waehrend die FIN daneben "" zeigte. Drei Leerdarstellungen in
einer Kachel. Wortgleich mit datum(fahrzeug.erstzulassung) in der App. */
function erstzulassungText() {
const z = datumAusText(CAR.erstzulassung);
if (z) return datumText(z.zeit, z.genau);
return (CAR.erstzulassung || "").trim() || "";
}
/* Datum + Uhrzeit in einer Zeile, für Start-/Zielzeitpunkt der Einzelfahrt. */
const dezeitpunkt = (d) => (gueltigesDatum(d)
? `${dedat(d)} · ${d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr`
@@ -1321,6 +1439,25 @@ const MARKEN = [
die Anzeige (Vorgabe des Eigentuemers, 04.09.2026).
Wortgrenzen sind Absicht: "Star" darf nicht in "Starnberg" treffen. */
/* Nimmt einem Namensteil die fuehrende Kopfmarke ab - wortgleich mit
ohneMarkeVorn() in companion-app/src/daten/tankstelle.ts.
Bis zum 04.09.2026 wurde ein Namensteil nur uebersprungen, wenn er FUER
SICH eine Marke ist. Ein von Hand eingetragener Name ohne Komma
("Shell München Ost") rutschte komplett durch und landete als ORT:
"Shell, Musterstr., Shell München Ost". Jetzt faellt die Marke vorn ab.
Steht die Marke NICHT am Anfang ("Autohof Shell Nord"), bleibt der Teil
unangetastet - dort ist nicht sicher, was Marke und was Ortsname ist. */
function ohneMarkeVorn(teil) {
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;
}
function markeErkennen(name) {
const roh = (name || "").trim();
if (!roh) return null;
@@ -1374,14 +1511,10 @@ function tankstelleTeile(name, adresse, marke) {
const teile = { marke: (marke || "").trim() || markeErkennen(name) || "", strasse: "", hausnummer: "", plz: "", ort: "", roh: (name || "").trim() };
const quellen = (adresse || "").split(",");
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.
const eigen = t ? markeErkennen(t) : null;
if (eigen && eigen.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) {
const t = stueck.trim();
@@ -2136,7 +2269,9 @@ function prognoseZeile(art) {
function terminText(t) {
const restKm = t.km !== null && CAR.odoBekannt ? t.km - CAR.odo : null;
const restKmAbs = restKm !== null ? Math.abs(restKm) : null;
const haupt = t.zuerst === "km" && restKm !== null ? "in " + de(restKmAbs) + " km" : dedat(t.datum);
// datumText statt dedat: eine monatsgenaue Faelligkeit ("08/2026") darf nicht
// als erfundener Erster des Monats erscheinen.
const haupt = t.zuerst === "km" && restKm !== null ? "in " + de(restKmAbs) + " km" : datumText(t.datum, t.genau);
const neben = t.zuerst === "km" ? "voraussichtlich " + dedat(t.kmDatum) + " · " + dedat(t.datum)
: (t.km !== null ? "in " + de(restKmAbs) + " km" : (t.art === "Hauptuntersuchung" ? "" : "aus dem Wartungsplan fortgeschrieben"));
return { haupt, neben, restKm };
@@ -2352,7 +2487,7 @@ function vAudi() {
<!-- Die technischen Daten stehen eine Ebene tiefer (Identität und Technik);
hier nur der Hinweis darauf, statt einer abgeschnittenen Motorzeile. -->
<div class="row"><dt>Details</dt><dd class="row-hinweis">Informationen über das Auto</dd></div>
<div class="row"><dt>Erstzulassung</dt><dd>${esc(CAR.erstzulassung)}</dd></div>
<div class="row"><dt>Erstzulassung</dt><dd>${esc(erstzulassungText())}</dd></div>
<div class="row"><dt>Ausführung</dt><dd>${CONFIG.ausfuehrung ? esc(CONFIG.ausfuehrung) : "noch offen"}</dd></div>
<div class="row" style="border-bottom:0"><dt>Ausstattung</dt>
<dd>${CAR.ausstattung.reduce((n, g) => n + g.sonder.length, 0)} Positionen</dd></div>
@@ -2906,7 +3041,7 @@ function vIdent() {
<div class="row"><dt>Ausführung</dt><dd>${CONFIG.ausfuehrung ? esc(CONFIG.ausfuehrung) : "noch offen"}</dd></div>
<div class="row"><dt>FIN</dt><dd>${esc(CAR.fin)}</dd></div>
<div class="row"><dt>Motor und Leistung</dt><dd>${esc(CAR.details)}</dd></div>
<div class="row"><dt>Erstzulassung</dt><dd>${esc(CAR.erstzulassung)}</dd></div>
<div class="row"><dt>Erstzulassung</dt><dd>${esc(erstzulassungText())}</dd></div>
<div class="row"><dt>Kennzeichen</dt><dd>${esc(CONFIG.kennzeichen)}</dd></div>
<div class="row" style="border-bottom:0"><dt>Zulassung</dt><dd>Deutschland</dd></div>
</dl></div>
@@ -2987,6 +3122,29 @@ function gewaehlterTermin() {
return auswahl[Math.min(svAuswahl, auswahl.length - 1)];
}
/* Die Hauptuntersuchung hat drei moegliche Quellen, in dieser Reihenfolge:
1. die im Profil EINGETRAGENE Faelligkeit (CAR.hu). Sie steht auf dem
Pruefbericht und ist damit die belastbarste Angabe - bis zum
04.09.2026 hat das Panel sie gar nicht benutzt, obwohl sie
gespeichert war (Befund des Eigentuemers).
2. der letzte Servicebuch-Eintrag plus 24 Monate.
3. die Erstzulassung plus 24 Monate.
Zu 3.: das ging vorher NUR mit einer Erstzulassung im Format MM/JJJJ.
Ein vollstaendiges Datum (01.03.2025) - eine voellig richtige Eingabe -
liess den Termin still leer. datumAusText() nimmt jetzt beide Formen. */
function hauptuntersuchungsTermin(bauen) {
const gesetzt = datumAusText(CAR.hu);
if (gesetzt) {
return { art: "Hauptuntersuchung", km: null, datum: gesetzt.zeit, kmDatum: null,
zuerst: "zeit", basis: null, ziel: gesetzt.zeit, genau: gesetzt.genau };
}
const erst = datumAusText(CAR.erstzulassung);
return bauen("Hauptuntersuchung", letzterEintrag("hauptuntersuchung"), null, 24,
erst ? erst.zeit : null);
}
function termine() {
const oel = CAR.oel;
const oelKm = oel.modus === "hersteller" ? oel.herstellerKm : Math.min(oel.km, oel.herstellerKm);
@@ -3032,8 +3190,7 @@ function termine() {
return [
oelTermin,
inspTermin,
bauen("Hauptuntersuchung", letzterEintrag("hauptuntersuchung"), null, 24,
CAR.erstzulassung && CAR.erstzulassung.includes("/") ? `01.${CAR.erstzulassung.replace("/", ".")}` : null),
hauptuntersuchungsTermin(bauen),
];
}
@@ -3143,7 +3300,7 @@ function vVers() {
${ciSVG(CI.settingsS, 17)}
</button>
<div class="fig" style="font-size:46px;margin-top:12px">${eur(v.beitrag)}<span class="unit"></span></div>
<span class="label" style="margin-top:14px">${esc(v.zahlweise)} · enthält ${eur(v.steueranteil)} Versicherungsteuer (${esc(v.steuersatz)})</span>
<span class="label" style="margin-top:14px">${esc(v.zahlweise)} · enthält ${eur(v.steueranteil)} Versicherungsteuer${(v.steuersatz || "").trim() ? ` (${esc(v.steuersatz)})` : ""}</span>
<dl class="rows" style="margin-top:16px">
${v.teile.map((t, i) => `<div class="row" ${i === v.teile.length - 1 ? 'style="border-bottom:0"' : ""}>
<dt>${esc(t.bez)}${t.sf ? `<br><span style="color:var(--fg3);font-size:11px">${esc(t.sf)} · Beitragssatz ${esc(t.satz)}</span></dt>` : "</dt>"}
@@ -3211,7 +3368,7 @@ function vVertragsdetails() {
<div style="font-size:14px;margin-top:12px">Panne, Unfall, Diebstahl, Fahrerausfall</div>
<span class="label" style="margin-top:10px">Nummern und Leistungen für den Ernstfall</span>
</button>
${v.leistungen.map((g) => `<div class="tile"><span class="label">${esc(g.gruppe)}</span>
${v.leistungen.filter((g) => (g.gruppe || "").trim() || (g.posten || []).length).map((g) => `<div class="tile"><span class="label">${esc(g.gruppe)}</span>
<dl class="rows" style="margin-top:8px">
${g.posten.map((x, i) => `<div class="row" ${i === g.posten.length - 1 ? 'style="border-bottom:0"' : ""}>
<dt style="color:var(--fg)">${esc(x)}</dt><dd></dd></div>`).join("")}
@@ -4479,6 +4636,20 @@ let belegPopup = null;
let swipeAktivWrap = null, swipeStartX = 0, swipeStartY = 0, swipeGeste = null, swipeDX = 0;
let swipeWarGezogen = false;
const SWIPE_BREITE = 84;
/* Ab diesem Anteil der Zeilenbreite loescht das Loslassen sofort, ohne
zweiten Tipp - so macht es iOS, und genau das fehlte: ein voller Wisch
blieb wie ein kurzer an der Raste stehen (Wunsch des Eigentuemers,
04.09.2026). 0,55 haelt die Geste absichtsvoll: eine halbe Zeile ist beim
seitlichen Scrollen leicht versehentlich erreicht, eine klare Mehrheit
nicht. Wortgleich mit WISCH_VOLL_* in bausteine.tsx und
FULL_SWIPE_FRACTION in SwipeRow (Design-System). */
const SWIPE_VOLL_ANTEIL = 0.55;
const SWIPE_VOLL_MAX = 220;
const SWIPE_VOLL_MIN = 120;
function swipeVollAb(wrap) {
const breite = wrap ? wrap.offsetWidth : 0;
return -Math.min(SWIPE_VOLL_MAX, Math.max(SWIPE_VOLL_MIN, breite * SWIPE_VOLL_ANTEIL));
}
/* Ziehen zum Aktualisieren (nur Übersicht) - ptrY0 ist die Y-Position des
Fingers/der Maus beim Start, null wenn gerade keine Geste läuft. Eigene
@@ -5865,19 +6036,40 @@ function ereignisseVerdrahten() {
// audi-dashboard.css) - sonst blitzt er dauerhaft an den Zeilenraendern durch.
swipeAktivWrap.classList.add("wischt");
const basis = swipeAktivWrap.classList.contains("swiped") ? -SWIPE_BREITE : 0;
swipeDX = Math.max(-SWIPE_BREITE - 20, Math.min(0, basis + dx));
// Nicht mehr bei SWIPE_BREITE abschneiden: ohne den weiteren Weg gibt es
// keinen vollen Wisch und keine Rueckmeldung, dass er loescht.
swipeDX = Math.min(0, basis + dx);
const flaeche = swipeAktivWrap.querySelector(".swipe-delete");
if (flaeche) {
// Je weiter gezogen, desto mehr Rot - dieselbe Lesart wie auf iOS.
flaeche.style.width = Math.max(SWIPE_BREITE, -swipeDX) + "px";
flaeche.classList.toggle("swipe-delete--scharf", swipeDX <= swipeVollAb(swipeAktivWrap));
}
const inhalt = swipeAktivWrap.querySelector(".swipe-content");
inhalt.style.transition = "none";
inhalt.style.transform = `translateX(${swipeDX}px)`;
}, { passive: false });
function swipeBeenden() {
if (!swipeAktivWrap) return;
const wrapJetzt = swipeAktivWrap;
const flaecheJetzt = wrapJetzt.querySelector(".swipe-delete");
if (flaecheJetzt) { flaecheJetzt.style.width = ""; flaecheJetzt.classList.remove("swipe-delete--scharf"); }
const inhalt = swipeAktivWrap.querySelector(".swipe-content");
inhalt.style.transition = ""; inhalt.style.transform = "";
swipeAktivWrap.classList.remove("wischt");
if (swipeGeste === "x") {
if (swipeDX <= -SWIPE_BREITE / 2) { schliesseAlleSwipes(); swipeAktivWrap.classList.add("swiped"); }
else { swipeAktivWrap.classList.remove("swiped"); }
if (swipeDX <= swipeVollAb(wrapJetzt)) {
// Ganz durchgezogen: jetzt handeln. Die Rueckfrage bleibt - geloescht
// werden hier Fahrten, Belege und Messwerte, die nicht wiederkommen.
schliesseAlleSwipes();
const knopf = wrapJetzt.querySelector("[data-swipe-loeschen]");
if (knopf) {
const [art, id] = knopf.dataset.swipeLoeschen.split(":");
bestaetigen(LOESCH_TEXT[art], LOESCH_HINWEIS[art], "Löschen", () => { eintragLoeschen(art, id); schliesseAlleSwipes(); });
}
}
else if (swipeDX <= -SWIPE_BREITE / 2) { schliesseAlleSwipes(); wrapJetzt.classList.add("swiped"); }
else { wrapJetzt.classList.remove("swiped"); }
}
swipeAktivWrap = null; swipeGeste = null; swipeDX = 0;
// Der nachlaufende Klick derselben Geste feuert synchron noch in diesem
@@ -6170,7 +6362,7 @@ function ereignisseVerdrahten() {
smartdealPopupOffen = false; profilSpeichern(); render();
return;
}
if (e.target.closest("[data-bildgalerie]")) { bildWeiter(); return; }
if (e.target.closest("[data-bildgalerie]")) { if (galerieGewischt) { galerieGewischt = false; return; } bildWeiter(); return; }
if (e.target.closest("[data-sbneu]")) {
CAR.service.buch.unshift({ datum: dedat(heute()), km: CAR.odoBekannt ? CAR.odo : 0, art: SERVICE_ARTEN[0], werkstatt: CAR.service.autohaus.name, kosten: null, arbeiten: "" });
entwurfIndex = 0; go("sbuch", "0"); return;
@@ -7104,6 +7296,7 @@ class AudiDashboardPanel extends HTMLElement {
ereignisseVerdrahten();
randwischenVerdrahten();
standortMenuVerdrahten();
galerieWischenVerdrahten();
themeIcon();
setInterval(standAlterTicken, 1000);
@@ -25,7 +25,7 @@
--line:rgba(255,255,255,.13);
--line-strong:rgba(255,255,255,.24);
--fg:#FFFFFF; --fg2:#BFC4CC; --fg3:#8E8E93;
--ok:#30D158; --warn:#FFD60A; --bad:#FF453A;
--ok:#30D158; --warn:#FFD60A; --bad:#FF453A; --bad-flaeche:#D70015;
/* Ohne diese Angabe rendert der Browser native Steuerelemente (Dropdown-
Aufklapp-Liste bei <select>, Scrollbars) in seiner Standard-Hellansicht,
egal wie dunkel die App selbst ist - das liess z. B. "Modell" beim
@@ -45,7 +45,7 @@
auf 4,75:1 bzw. 5,30:1, ohne den ruhigen Gesamteindruck zu aendern.
Der Nachtwert bleibt #8E8E93 - dort sind es ohnehin 5,86:1. */
--fg:#000000; --fg2:#3C3C43; --fg3:#6B6B70;
--ok:#34C759; --warn:#FF9F0A; --bad:#FF3B30;
--ok:#34C759; --warn:#FF9F0A; --bad:#D70015; --bad-flaeche:#D70015;
--ios-sep:rgba(60,60,67,.16);
--ios-fill:rgba(120,120,128,.16);
--tile-deckend:#FFFFFF;
@@ -396,3 +396,30 @@ main#view{--seitenrand:16px;padding:0 var(--seitenrand) 30px;scroll-behavior:smo
/* Zeilen mit Wert plus Statuspunkt rechts (Sicherheitscheck) ruhiger setzen */
.row dd .dot{width:8px;height:8px}
/* --- Apple HIG: 44x44 pt als kleinstes Tippziel -------------------------
Wortgleich mit rahmen.css/screens.css der App (04.09.2026 gemessen).
Das Overlay laesst das Bild unveraendert und vergroessert nur, was der
Finger trifft; Mindesthoehen dort, wo das Element ohnehin Flaeche ist. */
#back, .ringsbtn, .icon-btn, .zahnrad {
position: relative;
}
#back::after, .ringsbtn::after, .icon-btn::after, .zahnrad::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 44px;
height: 44px;
border-radius: var(--r-pill);
}
.feld input, .feld select, .seg button {
min-height: 44px;
}
input[type="range"] {
min-height: 44px;
}
@@ -1103,7 +1103,7 @@ img.carfix.klein { aspect-ratio: auto; height: auto; }
.swipe-wrap.swiped .swipe-delete { visibility: visible; }
.swipe-loeschen {
width: 100%;
background: var(--bad); color: #fff; border: none;
background: var(--bad-flaeche, var(--bad)); color: #fff; border: none;
font-family: inherit; font-size: 13px;
letter-spacing: normal;
cursor: pointer;
@@ -1701,3 +1701,8 @@ img.carfix.klein { aspect-ratio: auto; height: auto; }
.debugknopf:active {
color: var(--fg2);
}
/* Voll durchgewischt: die Flaeche wird kraeftiger, damit vor dem Loslassen
ablesbar ist, dass jetzt geloescht wird und nicht nur aufgeklappt.
Wortgleich mit .dm-wisch__loeschen--scharf in der App. */
.swipe-delete--scharf { filter: brightness(1.12); }
@@ -1,7 +1,7 @@
{
"domain": "audi_dashboard",
"name": "Audi Dashboard",
"version": "2026.9.4.22",
"version": "2026.9.4.23",
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
"codeowners": [
@@ -59,7 +59,7 @@
}
.ads-action--destructive:hover:not(:disabled) {
background: var(--bad);
background: var(--bad-flaeche, var(--bad));
color: #fff;
filter: none;
}
@@ -26,3 +26,28 @@
opacity: .4;
cursor: default;
}
/* Apple HIG verlangt 44x44 pt als kleinstes Tippziel. Der Knopf SIEHT
weiterhin 34x34 aus - die Flaeche liegt unsichtbar darum.
Warum nicht einfach groesser machen: die Groessen sind ueber viele Sitzungen
am gerenderten Bild abgestimmt worden. Ein Overlay loest das Missverhaeltnis
zwischen Sichtbarem und Treffbarem, ohne das Bild anzufassen.
Am 04.09.2026 gemessen: 34x34, teils 24x24 - beim Zurueck-Pfeil, also dem
meistbenutzten Bedienelement der App. */
.ads-icon-button {
position: relative;
}
.ads-icon-button::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 44px;
height: 44px;
/* Nur Trefferflaeche, kein Aussehen. */
border-radius: 50%;
}
@@ -16,7 +16,7 @@
position: absolute;
inset: 0 0 0 auto;
width: 84px;
background: var(--bad);
background: var(--bad-flaeche, var(--bad));
color: #fff;
border: none;
font-family: inherit;
@@ -27,10 +27,17 @@
visibility: hidden;
}
.ads-swipe-wrap:has(.ads-swiped) .ads-swipe-delete {
.ads-swipe-wrap:has(.ads-swiped) .ads-swipe-delete,
.ads-swipe-wrap.ads-swiping .ads-swipe-delete {
visibility: visible;
}
/* Voll durchgewischt: die Flaeche wird kraeftiger, damit vor dem Loslassen
ablesbar ist, dass jetzt geloescht wird und nicht nur aufgeklappt. */
.ads-swipe-delete--armed {
filter: brightness(1.12);
}
.ads-swipe-delete:active {
filter: brightness(.88);
}
@@ -1,7 +1,20 @@
import * as React from "react";
const SWIPE_THRESHOLD = -40;
/** Past this the row snaps open and stays there, waiting for a tap. */
const SNAP_OPEN_AT = -40;
/** How far the opened row rests. */
const SWIPE_OPEN = -84;
/**
* Past this fraction of the row's width, releasing deletes straight away
* no second tap. That is what iOS does, and what this component was missing:
* a full swipe used to stop at the resting position like a short one.
*
* 0.55 keeps the gesture deliberate. Half a row is easy to reach by accident
* while scrolling a list sideways; a clear majority of the row is not.
*/
const FULL_SWIPE_FRACTION = 0.55;
/** Never ask for more than this, so a very wide row stays reachable. */
const FULL_SWIPE_MAX = 220;
/** Module-wide pub/sub so opening one row closes every other open row (mirrors the source app's schliesseAlleSwipes()). */
const listeners = new Set<() => void>();
@@ -33,6 +46,21 @@ export function SwipeRow({
const isControlled = swiped !== undefined;
const isSwiped = isControlled ? swiped : internalSwiped;
/** Live offset while a finger is down null when nothing is being dragged.
*
* Kept in a ref as well as in state, and the ref is the one that decides.
* State lags by a render: when the last pointermove and the pointerup
* arrive in the same task which is exactly what a fast flick does
* the release handler would still read `null` and the swipe would be
* lost. Measured on 2026-09-04: a full swipe did nothing at all. */
const [drag, setDragState] = React.useState<number | null>(null);
const dragRef = React.useRef<number | null>(null);
const setDrag = (value: number | null) => {
dragRef.current = value;
setDragState(value);
};
const wrapRef = React.useRef<HTMLDivElement | null>(null);
const setSwiped = React.useCallback(
(next: boolean) => {
if (!isControlled) setInternalSwiped(next);
@@ -50,43 +78,80 @@ export function SwipeRow({
};
}, [close]);
const fullSwipeAt = () => {
const width = wrapRef.current?.offsetWidth ?? 0;
return -Math.min(FULL_SWIPE_MAX, Math.max(120, width * FULL_SWIPE_FRACTION));
};
const dragStartX = React.useRef<number | null>(null);
const decided = React.useRef(false);
const onPointerDown = (e: React.PointerEvent) => {
dragStartX.current = e.clientX;
decided.current = false;
};
const onPointerMove = (e: React.PointerEvent) => {
if (dragStartX.current === null) return;
const delta = e.clientX - dragStartX.current;
if (delta < SWIPE_THRESHOLD) {
closeAllExcept(close);
setSwiped(true);
dragStartX.current = null;
} else if (delta > -SWIPE_THRESHOLD) {
setSwiped(false);
if (!decided.current) {
if (Math.abs(delta) < 8) return;
decided.current = true;
if (delta < 0) closeAllExcept(close);
}
// Follow the finger instead of jumping to the resting position: without a
// live offset there is no way to tell a short swipe from a full one, and
// no feedback that pulling further will delete.
const base = isSwiped ? SWIPE_OPEN : 0;
setDrag(Math.min(0, base + delta));
};
const onPointerUp = () => {
const finish = () => {
const offset = dragRef.current;
dragStartX.current = null;
decided.current = false;
setDrag(null);
if (offset === null) return;
if (offset <= fullSwipeAt()) {
// Pulled all the way through: act now. `onDelete` is the caller's
// handler and asks for confirmation where the data cannot come back.
setSwiped(false);
onDelete();
return;
}
setSwiped(offset <= SNAP_OPEN_AT);
};
const offset = drag ?? (isSwiped ? SWIPE_OPEN : 0);
const armed = offset <= fullSwipeAt();
const showing = isSwiped || drag !== null;
const classes = ["ads-swipe-wrap"];
if (showing) classes.push("ads-swiping");
if (className) classes.push(className);
return (
<div className={classes.join(" ")}>
<button type="button" className="ads-swipe-delete" onClick={onDelete}>
<div className={classes.join(" ")} ref={wrapRef}>
<button
type="button"
className={armed ? "ads-swipe-delete ads-swipe-delete--armed" : "ads-swipe-delete"}
// The panel grows with the drag so the row fills with red the further
// it is pulled — the same read as on iOS: colour means committed.
style={{ width: Math.max(84, -offset) }}
onClick={onDelete}
>
{deleteLabel}
</button>
<div
className={isSwiped ? "ads-swipe-content ads-swiped" : "ads-swipe-content"}
style={{ transform: isSwiped ? `translateX(${SWIPE_OPEN}px)` : undefined }}
style={{
transform: offset ? `translateX(${offset}px)` : undefined,
transition: drag === null ? undefined : "none",
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onPointerUp={finish}
onPointerCancel={finish}
>
{children}
</div>
+27 -1
View File
@@ -39,6 +39,17 @@
--ok: #30D158;
--warn: #FFD60A;
--bad: #FF453A;
/* Rot als FLAECHE, nicht als Schrift.
Weisse Schrift auf --bad ergab am 04.09.2026 gemessen 3,41 (Nacht) bzw.
3,55 (Tag) - unter den 4,5, die WCAG AA fuer 16px verlangt. Auf
#D70015 sind es 5,38, und das ist Apples eigenes systemRed fuer erhoehten
Kontrast, bleibt also innerhalb der HIG-Palette.
Getrennt vom Schrift-Rot, weil beide Rollen Gegensaetzliches brauchen:
rote Schrift will Abstand zum HELLEN Grund, eine rote Flaeche will
Abstand zur WEISSEN Schrift darauf. */
--bad-flaeche: #D70015;
--shade: rgba(255, 255, 255, .05);
/* Graue Füllfläche für Bedienelemente (Seg-Spur, Eingabe-/Auswahl-Boxen) -
live am Panel gemessen (--ios-fill, audi-dashboard-ios.css). */
@@ -73,7 +84,22 @@
--fg3: #6B6B70;
--ok: #34C759;
--warn: #FF9F0A;
--bad: #FF3B30;
/* #FF3B30 (Apples systemRed) auf #F2F2F7 ergab 3,18 - unter den 4,5 fuer
16px-Schrift. #D70015 ist Apples systemRed fuer erhoehten Kontrast und
kommt auf 4,83 (auf Weiss 5,38). Gemessen am 04.09.2026 am
Loesch-Knopf des Einzelbelegs. */
--bad: #D70015;
/* Rot als FLAECHE, nicht als Schrift.
Weisse Schrift auf --bad ergab am 04.09.2026 gemessen 3,41 (Nacht) bzw.
3,55 (Tag) - unter den 4,5, die WCAG AA fuer 16px verlangt. Auf
#D70015 sind es 5,38, und das ist Apples eigenes systemRed fuer erhoehten
Kontrast, bleibt also innerhalb der HIG-Palette.
Getrennt vom Schrift-Rot, weil beide Rollen Gegensaetzliches brauchen:
rote Schrift will Abstand zum HELLEN Grund, eine rote Flaeche will
Abstand zur WEISSEN Schrift darauf. */
--bad-flaeche: #D70015;
--shade: rgba(0, 0, 0, .04);
--ios-fill: rgba(120, 120, 128, .16);
--tile-deckend: #FFFFFF;