Rollende Zeitraeume, zwei Gesten, CI-Symbole und das Screening faellt nach (2026.9.2.8-.12)

Gemeldet vom Eigentuemer an der realen Instanz, in einem Zug abgearbeitet.

STATISTIK. "Monat kleiner als Woche" war kein Rechenfehler, sondern die
Definition: am 02.09. begann die Kalenderwoche am 31.08., der Monat erst am
01.09. Umgestellt auf rollende Fenster (1/7/30/365 Tage), damit 1 ⊂ 7 ⊂ 30 ⊂
365 an jedem Tag gilt. "Jahr" meint damit nicht mehr das Kalenderjahr - so
entschieden. Zwei neue Tests halten die Verschachtelung fest.

Aus dem Langzeitverbrauch sind Herleitung und Erklaerabsatz aus der ANSICHT
genommen, nicht aus der Rechnung; das Datum des letzten Tankstops ebenfalls.

UEBERSICHT. Unter "Letzter Tankvorgang" steht das Datum statt der Tankstelle.

BILDER. Die Draufsicht war der einzige sichtbare Bildplatz ohne Zugang -
bilder.py kannte den Dateinamen, BILDER/BILDPLAETZE nicht. Jetzt ein Platz wie
jeder andere. Die Vorschau in den Einstellungen schneidet Fotos nicht mehr an,
und der Bildbereich der Fahrzeugseite steht eingerueckt statt randlos.

ZAHNRAD. Es lag mit seiner 44px-Trefferflaeche 21px im Kachelinhalt und fing
die obere Haelfte des ersten Knopfes ab (Versicherung, "Inland"). Die Kopfzeile
bekommt jetzt die Hoehe, die es braucht.

SCREENING. Lief ausschliesslich bei einer Aenderung des Kilometerstands, also
nur waehrend der Fahrt - im Stand blieben Ortsnamen und Verbrauch liegen. Jetzt
zusaetzlich einmal nach dem Start, und danach faellt es selbst nach, solange
ein Lauf sein Ortsbudget aufbraucht UND dabei etwas aufloest. Kein Dauertakt:
die Kette endet, sobald ein Lauf mit uebrigem Budget durchkommt.

GESTEN. Zum Loeschen nach links wischen, jetzt auch im Radarchiv (das Panel
hatte die Mechanik, das Archiv war nie angeschlossen; die App hatte sie gar
nicht). Vom linken Rand nach rechts wischen = zurueck, jetzt auch in der App,
mit denselben Zahlen wie im Panel und in beiden mit sichtbarer Rueckmeldung.

CI-SYMBOLE. Das Kachel-Zahnrad ist settings-s - im Panel stand der Pfad
sechsmal wortgleich im Markup, jetzt einmal als CI.settingsS. Das
handgezeichnete Zahnrad der Seitenleiste ist ersetzt und geloescht. Die
Zapfsaeule des Tanken-Reiters ist fuel-station-s, Faktor 0,84 um die
Rastermitte - live per getBBox() gemessen, damit sie auf dieselbe senkrechte
Spanne kommt wie Statistik.

Zurueckgenommen: die Titel der Unterseiten stehen wieder mittig. Zentriert ist
Apples HIG fuer Navigationsleisten; die linksbuendigen Titel sind die der
Hauptbereiche und folgen der anderen Regel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 15:47:32 +02:00
parent e28abdce25
commit 38e7731a3d
23 changed files with 911 additions and 135 deletions
+127 -6
View File
@@ -8,7 +8,12 @@
*/
import { useEffect, useRef, useState } from "react"
import type { PointerEvent as ReactPointerEvent, ReactNode, RefObject } from "react"
import type {
MutableRefObject,
PointerEvent as ReactPointerEvent,
ReactNode,
RefObject,
} from "react"
import { TabBar } from "@audi-dash/ui"
@@ -24,7 +29,7 @@ import {
SymbolStatistik,
SymbolTanken,
SymbolUebersicht,
SymbolZahnrad,
SymbolZahnradVoll,
SymbolZurueck,
} from "./symbole"
@@ -45,9 +50,93 @@ export interface ShellProps {
/** Ziehen zum Aktualisieren, 1:1 nach der ptr-Logik des Panels. */
const PTR_SCHWELLE = 56
/*
* Vom linken Rand nach rechts wischen = zurück.
*
* Warum von Hand: die App ist EINE WebView mit einer flachen Route (siehe
* navigation.ts), kein Verlaufsstapel. iOS' eigene Zurück-Geste hängt am
* Navigationsstapel eines UINavigationControllers - den gibt es hier nicht,
* also gibt es die Geste auch nicht geschenkt. Bis zum 02.09.2026 war der
* Pfeil oben links der einzige Weg zurück (gemeldet vom Eigentümer).
*
* RAND: die Geste muss am linken Rand beginnen. Das ist keine Nachahmung um
* ihrer selbst willen, sondern die einzige Art, sie von allem anderen
* Waagerechten sauber zu trennen - dem Wischen an einer Listenzeile
* (WischLoeschen), dem seitlichen Schieben in der Fotogalerie.
*
* SCHWELLE: erst ab dieser Strecke wird zurückgegangen. Darunter federt die
* Seite zurück - eine angefangene Geste ist damit widerrufbar, indem man den
* Finger zurückführt, genau wie bei iOS.
*/
/* Dieselben Zahlen wie randwischenVerdrahten() im Panel (26px Rand, 62px
Auslösung) - die Geste soll sich in beiden Oberflächen gleich anfühlen. */
const ZURUECK_RAND = 26
const ZURUECK_SCHWELLE = 62
const ZURUECK_WEG = 140
function useWischZurueck(
inhalt: RefObject<HTMLElement | null>,
zurueck: (() => void) | undefined,
belegt: MutableRefObject<boolean>,
) {
const [zug, setzeZug] = useState<number | null>(null)
const start = useRef<{ x: number; y: number } | null>(null)
useEffect(() => {
const el = inhalt.current
// Der Aussteiger sitzt IM Effekt, nicht davor - sonst hinge die
// Hook-Reihenfolge davon ab, ob es ein Zurück-Ziel gibt.
if (!el || !zurueck) return undefined
const beiStart = (e: TouchEvent) => {
if (e.touches.length !== 1) return
const t = e.touches[0]!
if (t.clientX > ZURUECK_RAND) return
start.current = { x: t.clientX, y: t.clientY }
}
const beiBewegung = (e: TouchEvent) => {
const s = start.current
if (!s || e.touches.length !== 1) return
const t = e.touches[0]!
const dx = t.clientX - s.x
const dy = t.clientY - s.y
if (dx <= 0 || Math.abs(dx) < Math.abs(dy) * 1.5) {
// Senkrecht oder rückwärts gemeint: die Geste gehört uns nicht.
if (Math.abs(dy) > 10) start.current = null
return
}
// Solange wir ziehen, hält sich das Ziehen-zum-Aktualisieren heraus.
belegt.current = true
setzeZug(Math.min(ZURUECK_WEG, dx))
e.preventDefault()
}
const beiEnde = () => {
const gezogen = zug ?? 0
start.current = null
belegt.current = false
setzeZug(null)
if (gezogen >= ZURUECK_SCHWELLE) zurueck()
}
el.addEventListener("touchstart", beiStart, { passive: true })
el.addEventListener("touchmove", beiBewegung, { passive: false })
el.addEventListener("touchend", beiEnde)
el.addEventListener("touchcancel", beiEnde)
return () => {
el.removeEventListener("touchstart", beiStart)
el.removeEventListener("touchmove", beiBewegung)
el.removeEventListener("touchend", beiEnde)
el.removeEventListener("touchcancel", beiEnde)
}
})
return zug
}
function useZiehenAktualisieren(
inhalt: RefObject<HTMLElement | null>,
beiAktualisieren: (() => Promise<void>) | undefined,
belegt: MutableRefObject<boolean>,
) {
const [zug, setzeZug] = useState(0)
const [laedt, setzeLaedt] = useState(false)
@@ -68,7 +157,7 @@ function useZiehenAktualisieren(
}
const starten = (y: number) => {
if (laedt) return
if (laedt || belegt.current) return
if ((inhalt.current?.scrollTop ?? 0) > 0) return
start.current = y
setzeZug(0)
@@ -77,6 +166,11 @@ function useZiehenAktualisieren(
preventDefault() rufen. */
const bewegen = (y: number): boolean => {
if (start.current === null) return false
if (belegt.current) {
start.current = null
setzeZug(0)
return false
}
const dy = y - start.current
if (dy > 0 && (inhalt.current?.scrollTop ?? 0) <= 0) {
// Widerstand wie bei iOS: halbe Strecke, gedeckelt bei 90px.
@@ -168,7 +262,20 @@ export function Shell({
const unterseite = !istHauptbereich(route.name)
const inhaltRef = useRef<HTMLElement | null>(null)
const ptr = useZiehenAktualisieren(inhaltRef, breit ? undefined : beiAktualisieren)
// Eine Geste zur Zeit: läuft der Wisch nach rechts, hält sich das Ziehen
// zum Aktualisieren heraus. Ohne das zeigte eine schräge Geste am oberen
// Seitenrand beides gleichzeitig.
const gesteBelegt = useRef(false)
const zurueckZug = useWischZurueck(
inhaltRef,
zurueckZiel && !breit ? () => geheZu(zurueckZiel) : undefined,
gesteBelegt,
)
const ptr = useZiehenAktualisieren(
inhaltRef,
breit ? undefined : beiAktualisieren,
gesteBelegt,
)
// Der Text tickt mit, wie standAlterTicken() im Panel (Sekundentakt).
const [, neuZeichnen] = useState(0)
@@ -273,7 +380,7 @@ export function Shell({
}
onClick={() => geheZu("einst")}
>
<SymbolZahnrad />
<SymbolZahnradVoll />
<span>Einstellungen</span>
</button>
</div>
@@ -314,7 +421,21 @@ export function Shell({
: "Ziehen zum Aktualisieren"}
</span>
</div>
<main className="dm-scroll dm-inhalt" ref={inhaltRef} {...ptr.handler}>
<main
className="dm-scroll dm-inhalt"
ref={inhaltRef}
{...ptr.handler}
/* Die Seite folgt dem Finger, damit die Geste sichtbar widerrufbar
bleibt: zurückführen unter die Schwelle, und sie federt zurück.
Ohne Zug steht hier kein transform - eine stehende Transformation
erzeugt einen eigenen Malkontext und hätte position:fixed im Inhalt
an sich gebunden. */
style={
zurueckZug === null
? undefined
: { transform: `translateX(${zurueckZug}px)`, transition: "none" }
}
>
{children}
</main>
<TabBar
+37 -14
View File
@@ -46,25 +46,46 @@ function alsTag(d: Date): string {
}
describe("zeitraumStart", () => {
it("beginnt das Jahr am 1. Januar", () => {
expect(alsTag(zeitraumStart("jahr", JETZT))).toBe("2026-01-01")
it("rechnet das Jahr 365 Tage zurück", () => {
expect(alsTag(zeitraumStart("jahr", JETZT))).toBe("2025-08-11")
})
it("beginnt den Monat am Ersten", () => {
expect(alsTag(zeitraumStart("monat", JETZT))).toBe("2026-08-01")
it("rechnet den Monat 30 Tage zurück", () => {
expect(alsTag(zeitraumStart("monat", JETZT))).toBe("2026-07-12")
})
it("beginnt die Woche am Montag, nicht am Sonntag", () => {
// Der 11.08.2026 ist ein Dienstag, die Woche beginnt am 10.08.
const start = zeitraumStart("woche", JETZT)
expect(start.getDay()).toBe(1)
expect(start.getDate()).toBe(10)
it("rechnet die Woche 7 Tage zurück", () => {
expect(alsTag(zeitraumStart("woche", JETZT))).toBe("2026-08-04")
})
it("beginnt den Tag um Mitternacht", () => {
it("rechnet den Tag 24 Stunden zurück", () => {
const start = zeitraumStart("tag", JETZT)
expect(start.getHours()).toBe(0)
expect(start.getDate()).toBe(11)
expect(alsTag(start)).toBe("2026-08-10")
expect(start.getHours()).toBe(12)
})
/* Der Grund für die Umstellung, als Test: am 02.09.2026 begann die
Kalenderwoche am Montag, dem 31.08., der Kalendermonat aber erst am
01.09. Eine Fahrt vom 31.08. zählte damit zur Woche und nicht zum
Monat - "Monat kleiner als Woche", vom Eigentümer an der realen Instanz
gemeldet. Rollende Fenster sind ineinander enthalten, immer. */
it("hält Tag, Woche, Monat und Jahr ineinander verschachtelt", () => {
for (const tag of ["2026-09-02T09:00:00", "2026-01-01T09:00:00", "2026-03-01T00:30:00"]) {
const jetzt = new Date(tag)
const [j, m, w, t] = (["jahr", "monat", "woche", "tag"] as const).map((art) =>
zeitraumStart(art, jetzt).getTime(),
)
expect(j!).toBeLessThanOrEqual(m!)
expect(m!).toBeLessThanOrEqual(w!)
expect(w!).toBeLessThanOrEqual(t!)
}
})
it("zählt eine Fahrt vom Monatsletzten in Woche UND Monat", () => {
const jetzt = new Date("2026-09-02T09:00:00")
const fahrten = [fahrt({ trip_id: "a", ts_start: "2026-08-31T08:00:00" })]
expect(fahrtenSeit(fahrten, "woche", jetzt)).toHaveLength(1)
expect(fahrtenSeit(fahrten, "monat", jetzt)).toHaveLength(1)
})
})
@@ -91,7 +112,9 @@ describe("fahrtenSeit und tankSeit", () => {
fahrt({ trip_id: "heute", ts_start: "2026-08-11T09:00:00" }),
fahrt({ trip_id: "diesewoche", ts_start: "2026-08-10T09:00:00" }),
fahrt({ trip_id: "diesenmonat", ts_start: "2026-08-02T09:00:00" }),
fahrt({ trip_id: "letztesjahr", ts_start: "2025-12-30T09:00:00" }),
// Im Kalenderjahr 2026 nicht enthalten, in den letzten 365 Tagen schon -
// seit der Umstellung auf rollende Fenster zählt sie deshalb mit.
fahrt({ trip_id: "dezember", ts_start: "2025-12-30T09:00:00" }),
fahrt({ trip_id: "ohnestrecke", ts_start: "2026-08-11T10:00:00", distance_km: null }),
]
@@ -101,7 +124,7 @@ describe("fahrtenSeit und tankSeit", () => {
"heute",
"diesewoche",
])
expect(fahrtenSeit(fahrten, "jahr", JETZT)).toHaveLength(3)
expect(fahrtenSeit(fahrten, "jahr", JETZT)).toHaveLength(4)
})
it("lässt Fahrten ohne Strecke außen vor — sie würden jede Summe verfälschen", () => {
+23 -7
View File
@@ -31,16 +31,32 @@ export const ZEITRAUM_NAME: Record<Zeitraum, string> = {
absolut: "Absolut",
}
/** Beginn des Zeitraums. Die Woche startet montags, nicht sonntags. */
/**
* Länge der rollenden Zeiträume in Tagen.
*
* Bis zum 02.09.2026 waren die Spannen kalendarisch: Jahr ab 1. Januar, Monat
* ab dem Ersten, Woche ab Montag, Tag ab Mitternacht. Das ergab regelmäßig
* einen unmöglichen Vergleich — am 02.09.2026 stand in der realen Instanz
* "Monat" UNTER "Woche", weil die Woche am Montag, dem 31.08., begann und
* damit zwei Tage mitzählte, die der Monat nicht kennt. Zwischen Monat und
* Jahr passiert dasselbe am Jahreswechsel.
*
* Rollende Fenster können das nicht: 1 ⊂ 7 ⊂ 30 ⊂ 365 gilt an jedem Tag des
* Jahres. Der Preis ist, dass "Jahr" nicht mehr das Kalenderjahr meint,
* sondern die letzten 365 Tage — so entschieden vom Eigentümer am 02.09.2026.
*/
export const ZEITRAUM_TAGE: Record<Exclude<Zeitraum, "absolut">, number> = {
jahr: 365,
monat: 30,
woche: 7,
tag: 1,
}
/** Beginn des Zeitraums: so viele Tage zurück, wie ZEITRAUM_TAGE sagt. */
export function zeitraumStart(art: Zeitraum, jetzt = new Date()): Date {
// Vor jeder denkbaren Aufzeichnung - damit fällt nichts heraus.
if (art === "absolut") return new Date(0)
if (art === "jahr") return new Date(jetzt.getFullYear(), 0, 1)
if (art === "monat") return new Date(jetzt.getFullYear(), jetzt.getMonth(), 1)
const d = new Date(jetzt)
d.setHours(0, 0, 0, 0)
if (art === "woche") d.setDate(d.getDate() - ((d.getDay() + 6) % 7))
return d
return new Date(jetzt.getTime() - ZEITRAUM_TAGE[art] * 24 * 60 * 60 * 1000)
}
export function fahrtenSeit(fahrten: readonly Fahrt[], art: Zeitraum, jetzt = new Date()): Fahrt[] {
+1 -1
View File
@@ -400,7 +400,7 @@ export function Einstellungen({
{/* Vorher stand hier ein Raster aller sieben Miniaturen und der Hinweis,
Fotos seien nur über Home Assistant zu pflegen — das Panel zeigt
genau die gewählte Ansicht groß und lädt direkt hoch. */}
<div style={{ marginTop: "var(--sp-3)" }}>
<div className="dm-einstellbild" style={{ marginTop: "var(--sp-3)" }}>
<BildMitMenue
datei={gewaehltesBild.datei}
beschreibung={gewaehltesBild.beschreibung}
+4 -3
View File
@@ -46,9 +46,10 @@ export function Fahrzeugstatus({ geheZu }: { geheZu: (name: SeitenName) => void
return (
<>
{/* Draufsicht, wie im Panel (.szene.bleed, "carfix klein" = 16:8) - war
hier komplett unportiert (Parität-Audit 2026-08-30). */}
<div className="dm-szene">
{/* Draufsicht, wie im Panel (.szene.bleed.schmal, "carfix klein") - war
hier komplett unportiert (Parität-Audit 2026-08-30). Seit dem
02.09.2026 eingerückt statt randlos, siehe .dm-szene--schmal. */}
<div className="dm-szene dm-szene--schmal">
<Bild datei="draufsicht.webp" beschreibung="Draufsicht" name="Draufsicht" />
</div>
+3 -1
View File
@@ -16,7 +16,7 @@ import { useDaten } from "../daten/DatenKontext"
import type { Reifensatz, ReifenArchivEintrag } from "../daten/profilAdapter"
import { datum, de, deOderStrich } from "../format"
import { SymbolEdit, SymbolPaket, SymbolZahnradVoll } from "../symbole"
import { BildMitMenue, Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { BildMitMenue, WischLoeschen, Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { Datumsfeld } from "./Datumsfeld"
import { kalenderDateiLaden } from "./kalender"
@@ -374,6 +374,7 @@ function ArchivZeile({ eintrag }: { eintrag: ReifenArchivEintrag }) {
}
return (
<WischLoeschen beiLoeschen={() => void loeschen()}>
<Accordion
title={eintrag.satz === "winter" ? "Winterräder" : "Sommerräder"}
summary={
@@ -499,5 +500,6 @@ function ArchivZeile({ eintrag }: { eintrag: ReifenArchivEintrag }) {
</>
)}
</Accordion>
</WischLoeschen>
)
}
+11 -29
View File
@@ -16,7 +16,7 @@ import { Fig, ProgressBar, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { ZEITRAUM_NAME, ZEITRAUM_SPALTEN, jahresauswertung } from "../daten/statistik"
import type { Zeitraum } from "../daten/statistik"
import { datum, de, deOderStrich } from "../format"
import { de, deOderStrich } from "../format"
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
/** `statTile()` im Panel: Kopf mit Zahl und Nebenwert, aufklappbarer Rumpf. */
@@ -150,39 +150,21 @@ export function Statistik() {
titel="Langzeitverbrauch"
wert={deOderStrich(a.langzeit.wert, 1)}
einheit="l/100 km"
neben={a.langzeit.bis ? `bis ${datum(a.langzeit.bis)}` : ""}
/* Das Datum des letzten Tankstops ist am 02.09.2026 auf Ansage des
Eigentümers auch aus der Kopfzeile verschwunden - es war der letzte
Rest der Herleitung, die aus dem Rumpf schon raus ist. */
neben=""
>
<Werteliste
kinder={
<>
<Wertzeile label="Getankt insgesamt" wert={`${de(a.langzeit.liter, 1)} l`} />
<Wertzeile
label="Strecke bis zum letzten Tankstop"
wert={`${de(a.langzeit.km)} km`}
zusatz={a.langzeit.basis !== "" ? a.langzeit.basis : undefined}
/>
<Wertzeile
label="Letzter Tankstop"
wert={a.langzeit.bis ? datum(a.langzeit.bis) : ""}
/>
</>
}
/>
{a.langzeit.unplausibel && (
<span className="ads-eyebrow dm-warnton" style={{ marginTop: "16px" }}>
Aus Litern und Strecke ergibt sich derzeit kein plausibler Wert. Sobald Tankvorgänge und
Kilometerstände vollständig vorliegen, erscheint er hier.
</span>
)}
{/* Die Herleitung des Langzeitwerts — getankte Liter, Bezugsstrecke,
letzter Tankstop, der Unplausibel-Hinweis und der Erklärabsatz —
ist am 02.09.2026 auf Ansage des Eigentümers aus der ANSICHT
genommen worden, nicht aus der Rechnung: langzeitverbrauch()
rechnet unverändert weiter und liefert liter/km/basis nach wie
vor. Es bleiben der Wert selbst und die Näherung je Zeitraum. */}
<Vierergruppe
titel="Näherung je Fahrt, je Zeitraum [l/100 km]"
werte={jeZeitraum((z) => deOderStrich(a.jeZeitraum[z].verbrauch, 1))}
/>
<span className="ads-eyebrow" style={{ marginTop: "16px" }}>
Der Langzeitwert rechnet alle je getankten Liter gegen die Strecke bis zum letzten
Tankstop. Die Werte je Zeitraum sind eine Näherung aus der Tankfüllstand-Differenz der
einzelnen Fahrten, keine Meldung des Fahrzeugs.
</span>
</StatKachel>
<StatKachel
+4 -1
View File
@@ -253,7 +253,10 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
{letzteTankung && (
<Blattzeile
label="Letzter Tankvorgang"
labelZusatz={letzteTankung.station_name ?? ""}
// Bis zum 02.09.2026 stand hier der Name der Tankstelle. Das
// Datum ist die Angabe, die auf der Startseite zählt — wann war
// ich zuletzt tanken; der Name steht auf der Detailseite.
labelZusatz={datum(letzteTankung.ts)}
wert={letzteTankung.liters != null ? `${de(letzteTankung.liters, 2)} l` : ""}
wertZusatz={
letzteTankung.fuel_total_eur != null
+108 -1
View File
@@ -3,7 +3,7 @@
* gebraucht wird, aber zu speziell für das Design-System ist.
*/
import { useRef, useState, type ReactNode } from "react"
import { useRef, useState, type ReactNode, type TouchEvent as ReactTouchEvent } from "react"
import { ActionButton, ActionSheet, Tile } from "@audi-dash/ui"
@@ -307,6 +307,113 @@ export function StatusKreis({ ok }: { ok: boolean | null }) {
)
}
/**
* Zum Löschen nach links wischen — die iOS-übliche Geste für Listenzeilen.
*
* Warum überhaupt: der Löschknopf des archivierten Radsatzes sitzt IN der
* aufgeklappten Zeile. Wer löschen will, muss also erst aufklappen, an sieben
* Eingabefeldern vorbeiscrollen und unten den Knopf finden. Auf Ansage des
* Eigentümers am 02.09.2026.
*
* Bewusst NICHT die volle iOS-Mechanik: kein Löschen durch Durchwischen, die
* Geste legt nur den Knopf frei. Ein archivierter Satz ist unwiederbringlich,
* und ein Wisch ist zu leicht versehentlich gemacht. Die Sicherheitsabfrage
* dahinter bleibt ebenfalls.
*
* Die Geste greift erst, wenn sie waagerecht deutlich stärker ist als
* senkrecht (Faktor 1,5) — sonst nähme sie jeder Scrollbewegung die Führung
* weg, die zufällig ein paar Pixel seitlich verläuft.
*/
const WISCH_BREITE = 88
const WISCH_START = 10
export function WischLoeschen({
beiLoeschen,
beschriftung = "Löschen",
children,
}: {
beiLoeschen: () => void
beschriftung?: string
/** Heißt `children`, weil JSX-Verschachtelung genau diesen Namen erwartet. */
children: ReactNode
}) {
const [offen, setzeOffen] = useState(false)
const [zug, setzeZug] = useState<number | null>(null)
const start = useRef<{ x: number; y: number } | null>(null)
const entschieden = useRef(false)
const versatz = zug ?? (offen ? -WISCH_BREITE : 0)
const beiStart = (e: ReactTouchEvent<HTMLDivElement>) => {
if (e.touches.length !== 1) return
start.current = { x: e.touches[0]!.clientX, y: e.touches[0]!.clientY }
entschieden.current = false
}
const beiBewegung = (e: ReactTouchEvent<HTMLDivElement>) => {
const s = start.current
if (!s || e.touches.length !== 1) return
const dx = e.touches[0]!.clientX - s.x
const dy = e.touches[0]!.clientY - s.y
if (!entschieden.current) {
if (Math.abs(dx) < WISCH_START) return
// Senkrecht gemeint: die Geste gehört der Liste, nicht uns.
if (Math.abs(dx) < Math.abs(dy) * 1.5) {
start.current = null
return
}
entschieden.current = true
}
const basis = offen ? -WISCH_BREITE : 0
setzeZug(Math.max(-WISCH_BREITE, Math.min(0, basis + dx)))
}
const beiEnde = () => {
if (zug !== null) setzeOffen(zug < -WISCH_BREITE / 2)
start.current = null
setzeZug(null)
}
return (
<div className="dm-wisch">
<button
type="button"
className="dm-wisch__loeschen"
tabIndex={offen ? 0 : -1}
aria-hidden={!offen}
onClick={() => {
setzeOffen(false)
beiLoeschen()
}}
>
{beschriftung}
</button>
<div
className="dm-wisch__inhalt"
style={{
transform: `translateX(${versatz}px)`,
transition: zug === null ? "transform .18s ease" : "none",
}}
onTouchStart={beiStart}
onTouchMove={beiBewegung}
onTouchEnd={beiEnde}
onTouchCancel={beiEnde}
/* Ist der Knopf offen, schließt die nächste Berührung ihn wieder,
statt die Zeile aufzuklappen - sonst wäre der einzige Weg zurück,
erneut zu wischen. */
onClickCapture={(e) => {
if (!offen) return
e.preventDefault()
e.stopPropagation()
setzeOffen(false)
}}
>
{children}
</div>
</div>
)
}
/** Fasst ok über eine Gruppe zusammen: unbekannt, sobald ein Punkt unbekannt
ist oder die Gruppe ganz ohne Einträge dasteht (kein Sensor dieser Rolle
zugeordnet) - nie true durch eine leere Liste erschlichen. */
+6 -2
View File
@@ -19,7 +19,7 @@ export interface Bildplatz {
datei: string
}
/** Die sieben Plätze der Fotogalerie unter „Mein Audi". */
/** Die acht Plätze der Fotogalerie unter „Mein Audi". */
export const BILDPLAETZE: Bildplatz[] = [
{ name: "Seitenansicht", beschreibung: "Auto Seite", datei: "seitenansicht.webp" },
{ name: "Front schräg", beschreibung: "Auto Front schräg", datei: "front-schraeg.webp" },
@@ -28,6 +28,11 @@ export const BILDPLAETZE: Bildplatz[] = [
{ name: "Cockpit", beschreibung: "Innenraum Cockpit", datei: "cockpit.webp" },
{ name: "Scheinwerfer", beschreibung: "Scheinwerfer", datei: "scheinwerfer.webp" },
{ name: "Sitze", beschreibung: "Innenraum Sitze", datei: "sitze.webp" },
// Steht auf der Seite „Fahrzeug" über den Sicherheitszeilen. Bis zum
// 02.09.2026 war sie der einzige sichtbare Bildplatz, der in den
// Einstellungen NICHT auswählbar war — hochladen ließ sie sich damit gar
// nicht (gemeldet vom Eigentümer). bilder.py kannte den Dateinamen längst.
{ name: "Draufsicht", beschreibung: "Auto von oben", datei: "draufsicht.webp" },
]
/**
@@ -55,7 +60,6 @@ export const WEITERE_BILDER = [
"seitenansicht-winter.webp",
"rad-sommer.webp",
"rad-winter.webp",
"draufsicht.webp",
] as const
export const ERLAUBTE_DATEINAMEN: readonly string[] = [
+11
View File
@@ -48,6 +48,17 @@
min-width: 0;
}
/* Mittig, und zwar mit Absicht: Apples Human Interface Guidelines kennen
genau zwei Titelformen - den großen, linksbündigen Titel auf oberster
Ebene und den kleinen, ZENTRIERTEN in der Navigationsleiste einer
Detailseite. Beides steht hier nebeneinander: Hauptbereiche groß und
links, Unterseiten 17px und mittig.
Am 02.09.2026 kurzzeitig auf linksbündig gestellt (auf Ansage, "wie alle
anderen auch") und auf Rückfrage des Eigentümers am selben Tag wieder
zurückgenommen: die anderen Titel sind die der Hauptbereiche, und die
folgen der anderen Regel. Nicht erneut ändern, ohne diese beiden Formen
auseinanderzuhalten. */
.dm-kopf--unterseite .dm-kopf__titel {
text-align: center;
}
+97 -2
View File
@@ -323,6 +323,86 @@ input[type="date"].dm-eingabe {
display: none;
}
/* Die Vorschau in den Einstellungen zeigt das ganze Foto.
Vorher galt der Regelkasten 4:3 mit object-fit:cover - ein Foto anderen
Formats wurde oben und unten beschnitten, bei der Frontansicht besonders
deutlich (gemeldet 02.09.2026). Wer prüfen will, ob das richtige Bild
hochgeladen ist, muss es ganz sehen. Dieselbe Entscheidung wie beim
Fahrzeugfoto der Übersicht (.carfix.klein im Panel): das Bild bestimmt die
Höhe, nicht der Kasten. Nur der Platzhalter behält ein festes Verhältnis,
er hat ja keines von sich aus. */
.dm-einstellbild .ads-bildbox {
aspect-ratio: auto;
height: auto;
}
.dm-einstellbild .ads-bildbox img {
height: auto;
}
.dm-einstellbild .ads-bildbox:has(.ads-platzhalter) {
aspect-ratio: 4 / 3;
}
/* Die Draufsicht auf der Fahrzeugseite ist eine Beigabe, keine Szene: sie
steht über den Sicherheitszeilen und soll die Zeilen nicht nach unten
drängen. Statt randlos über die volle Breite (rund 374px auf einem Telefon,
mit 16:8 also 187px hoch) steht sie eingerückt und mittig — 60% Breite, die
Höhe geht im selben Verhältnis mit auf rund 112px zurück. Auf Ansage des
Eigentümers am 02.09.2026 („Draufsicht-Foto Bereich ist zu groß"):
schmaler, nicht flacher — flacher hätte bei einem Foto, das nicht sehr
breit ist, oben und unten geschnitten. Gleichlautend .szene.bleed.schmal
im Panel. Der :has()-Zweig oben setzt die Ränder bereits auf 0, wenn kein
Foto da ist; hier gilt es unabhängig davon. */
.dm-szene--schmal {
/* Der Abstand zum Fahrzeug kommt jetzt aus dem Layout, nicht aus leerem
Bildrand: das Foto ist seit dem 02.09.2026 freigestellt und eng
beschnitten. Oben und unten Luft wie im Vorbild des Eigentümers. */
margin: 0;
padding: 22px 0 26px;
}
.dm-szene--schmal .ads-bildbox {
width: 60%;
margin: 0 auto;
}
/* Zum Löschen nach links wischen (WischLoeschen in bausteine.tsx).
Der Knopf liegt fest hinter dem Inhalt, verschoben wird nur der Inhalt
darüber — deshalb braucht er einen DECKENDEN Grund, sonst schiene das Rot
hindurch. --tile ist der Grund der Kachel, in der die Zeile sitzt.
touch-action: pan-y sagt dem Browser, dass die waagerechte Achse uns
gehört: senkrecht scrollt er weiter selbst, waagerecht wartet er auf uns,
statt die Geste nach ein paar Pixeln zu übernehmen. */
.dm-wisch {
position: relative;
overflow: hidden;
}
.dm-wisch__loeschen {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 88px;
border: 0;
padding: 0;
background: var(--bad);
color: #fff;
font: inherit;
font-size: 15px;
cursor: pointer;
}
.dm-wisch__inhalt {
position: relative;
background: var(--tile);
touch-action: pan-y;
}
.dm-szene .ads-bild,
.dm-szene img {
width: 100%;
@@ -411,10 +491,25 @@ button.dm-serviceblock:active {
padding-right: 44px;
}
/* Das Zahnrad ist 44px hoch (Trefferfläche), die Überschrift darunter nur
12px. Ohne reservierte Zeile ragte es also 20px in den Kachelinhalt und lag
sichtbar auf dem ersten Knopf - auf der Versicherungsseite auf "Inland ·
<Nummer>", von dort auch gemeldet (02.09.2026). Es fing dessen obere Hälfte
ab: der Knopf ließ sich am oberen Rand nicht wählen.
Statt das Zahnrad zu verkleinern (44px sind die Mindestgröße einer
Trefferfläche) bekommt die Kopfzeile die Höhe, die es braucht. Gleiche
Regel im Panel (.tile:has(> .zahnrad)). */
.dm-kachel--zahnrad > .ads-eyebrow:first-child,
.dm-kachel--zahnrad > .ads-title:first-child {
min-height: 34px;
padding-right: 48px;
}
.dm-zahnrad {
position: absolute;
top: 11px;
right: 11px;
top: 8px;
right: 8px;
width: 44px;
height: 44px;
display: inline-flex;
+33 -23
View File
@@ -154,17 +154,26 @@ export function SymbolPaket({ groesse }: SymbolProps) {
)
}
/* fuel-station-s aus dem Audi-CI-Satz (24er-Raster), verbatim - derselbe
Pfad wie CI.fuelStationS im Panel. Ersetzt seit dem 02.09.2026 eine
handgezeichnete Zapfsäule.
Der Faktor 0,84 um die Rastermitte hat denselben Grund wie bei
SymbolStatistik: das CI-Symbol ist randlos gezeichnet und stünde
ungeskaliert mit 22 von 24 Einheiten Höhe als höchstes Symbol der Leiste
da. Skaliert sind es 15,96 x 18,48 - dieselbe senkrechte Spanne wie
Statistik (y 2,76 bis 21,24), live per getBBox() gemessen, Ränder
eingeschlossen.
Die 48er-Variante hieße fuel-station-l und wird nicht gebraucht: die
Reiter zeichnen mit 21 bzw. 26px, also im 24er-Raster. */
export function SymbolTanken({ groesse }: SymbolProps) {
return (
<Rahmen
<RahmenFlaeche
groesse={groesse}
kinder={
<g>
<path d="M4.5 20V6.2c0-.9.8-1.7 1.7-1.7h5.6c1 0 1.7.8 1.7 1.7V20" />
<path d="M3 20h12.5" />
<path d="M4.5 11h10.5" />
<path d="M17 20v-8.5a2 2 0 0 0-2-2" />
<path d="M17 11.5V7.8l-2-2.3" />
<g transform="translate(1.5,1.92) scale(0.84)">
<path fill="currentColor" d="M4 3.5A2.5 2.5 0 0 1 6.5 1h6A2.5 2.5 0 0 1 15 3.5V10h.5a2.5 2.5 0 0 1 2.5 2.5v5a1.5 1.5 0 0 0 3 0V8.07a16.4 16.4 0 0 0-2.89-5.76l.78-.62a17.4 17.4 0 0 1 3.1 6.17l.01.07v9.57a2.5 2.5 0 0 1-5 0v-5c0-.83-.67-1.5-1.5-1.5H15v11h1v1H3v-1h1zM5 22h9V3.5c0-.83-.67-1.5-1.5-1.5h-6C5.67 2 5 2.67 5 3.5zm1-12h7v1H6z" />
</g>
}
/>
@@ -175,23 +184,24 @@ export function SymbolZurueck({ groesse }: SymbolProps) {
return <Rahmen groesse={groesse} kinder={<path d="M15 5l-7 7 7 7" />} />
}
export function SymbolZahnrad({ groesse }: SymbolProps) {
return (
<Rahmen
groesse={groesse}
kinder={
<g>
<circle cx="12" cy="12" r="3" />
<path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" />
</g>
}
/>
)
}
/* Hier stand bis zum 02.09.2026 SymbolZahnrad: ein selbst gezeichnetes
Zahnrad (Kreis mit acht geraden Speichen), das nur in der Seitenleiste des
Flächenlayouts auftauchte - auf einem Telefon also nie. Ersetzt durch
settings-s aus dem CI-Satz, dasselbe Symbol wie in den Kachel-Ecken. Es
stand ohnehin schon neben drei CI-Flächensymbolen (Mein Audi, Fahrten,
Statistik); das gezeichnete war der Ausreißer, nicht umgekehrt. */
/* Das Zahnrad der Kachel-Ecken (`.zahnrad` im Panel) — ein gefülltes Symbol
aus dem Audi-CI-Satz, verbatim übernommen. Nicht zu verwechseln mit
SymbolZahnrad oben, dem Kontur-Symbol der Seitenleiste. */
/* settings-s aus dem Audi-CI-Satz (24er-Raster), verbatim - dieselbe Datei
wie design/assets/icons/settings.svg und derselbe Pfad wie CI.settingsS im
Panel. Steht in der rechten oberen Ecke einer Kachel (`.zahnrad` dort).
Die 48er-Variante hieße settings-l; sie wird nirgends gebraucht, das
Zahnrad wird immer 17px dargestellt.
"Voll" im Namen meint die Fläche des CI-Symbols (ein gefüllter Pfad mit
Aussparungen), nicht eine gefüllte Variante der Bibliothek. Nicht zu
Bibliothek. Seit dem 02.09.2026 auch in der Seitenleiste des
Flächenlayouts - ein zweites, gezeichnetes Zahnrad gibt es nicht mehr. */
export function SymbolZahnradVoll({ groesse }: SymbolProps) {
return (
<RahmenFlaeche