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:
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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". */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`} />
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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"
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
Binary file not shown.
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user