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
+246
View File
@@ -7941,3 +7941,249 @@ matching SHA-256 and `bundle-version 2026.9.2.7` in the manifest.
build result.** Four of the five defects produced either a successful archive or a silent
misconfiguration; only unzipping the `.ipa` and reading entitlements, binaries and plists exposed
them.
---
## BP. Rollende Zeiträume, Gesten, CI-Symbole und das Screening fasst nach (2026.9.2.8-.12)
Vier Meldungen des Eigentümers an der realen Instanz, alle vom 02.09.2026.
### „Monat kleiner als Woche" — die Kalendergrenzen waren nicht ineinander enthalten
Gemeldet für **Gefahrene Distanz** und **Fahrzeit**: die Monatsspalte stand unter der Wochenspalte.
Kein Rechenfehler, sondern die Definition. Am 02.09.2026 begann die Kalenderwoche am Montag, dem
**31.08.**, der Kalendermonat aber erst am **01.09.** — die Woche zählte damit zwei Tage mit, die
der Monat nicht kennt. Zwischen Monat und Jahr passiert dasselbe an jedem Jahreswechsel: eine Fahrt
vom 30.12. gehört zur Woche, nicht zum Jahr.
Umgestellt auf **rollende Fenster**: Tag = 1, Woche = 7, Monat = 30, Jahr = 365 Tage zurück. So gilt
1 ⊂ 7 ⊂ 30 ⊂ 365 an jedem Tag des Jahres, ein unmöglicher Vergleich kann gar nicht mehr entstehen.
Der Preis, vom Eigentümer so entschieden: **„Jahr" ist nicht mehr das Kalenderjahr**, sondern die
letzten 365 Tage — das gilt auch für die Prozentwerte in „Tag und Nacht" und „Art der Fahrten", die
auf derselben Menge rechnen. „Absolut" bleibt unverändert alles je Erfasste.
`ZEITRAUM_TAGE` steht wortgleich in beiden Codebasen (`daten/statistik.ts`, `periodStart()` im
Panel). Zwei neue Tests halten die Regel fest: die Verschachtelung an drei kritischen Tagen
(02.09., 01.01., Monatserster) und die Fahrt vom Monatsletzten, die in Woche **und** Monat zählt.
### Langzeitverbrauch: Herleitung aus der Ansicht, nicht aus der Rechnung
Aus der Kachel entfernt: „Getankt insgesamt", „Strecke bis zum letzten Tankstop" samt Basiszeile
„Kilometerstand an den Tankvorgängen", „Letzter Tankstop", der Unplausibel-Hinweis und der
Erklärabsatz. **`langzeitverbrauch()` rechnet unverändert weiter** — `liter`, `km`, `basis` und
`unplausibel` stehen weiter im Rückgabewert, nur zeigt sie niemand mehr an. Es bleiben der Wert
selbst in der Kopfzeile und die Vierergruppe „Näherung je Fahrt, je Zeitraum".
### Die Draufsicht war der einzige Bildplatz ohne Zugang
Sie steht auf der Fahrzeugseite über den Sicherheitszeilen, `bilder.py` kennt `draufsicht.webp`
seit jeher in `ERLAUBTE_DATEINAMEN` — aber in `BILDER`/`BILDPLAETZE` fehlte sie, und genau diese
Liste baut die Auswahl in **Einstellungen → Bild der Übersicht**. Hochladen ließ sich das Foto
damit über keine der beiden Oberflächen. Jetzt ein Platz wie jeder andere (achter Eintrag), womit
er zugleich in der Galerie unter „Mein Audi" auftaucht — dieselbe Liste, dasselbe Verhalten.
### „Zuletzt": das Datum statt der Tankstelle
Unter „Letzter Tankvorgang" stand der Name der Tankstelle. Dort steht jetzt das **Datum** — die
Angabe, die auf der Startseite zählt; der Name steht auf der Detailseite. Der Gesamtpreis unter den
Litern war in beiden Oberflächen bereits da (`fuel_total_eur`) und erscheint, sobald der Beleg eine
Summe trägt.
### Das Screening lief nur, während das Fahrzeug fuhr
Der eigentliche Fund hinter „Start/Ziel: wird ermittelt …" auf der realen Instanz.
`screening.durchfuehren()` hing an **genau einem** Auslöser: `_kilometerstand_geaendert()`. Steht das
Fahrzeug, ändert sich der Kilometerstand nicht, und alles, was das Screening nachträgt — Ortsnamen,
Durchschnitt, Höchstgeschwindigkeit, Verbrauch, Radzähler — bleibt bis zur nächsten Fahrt liegen.
Nach einem Update ist das am deutlichsten: die neuen Felder der frisch installierten Fassung füllen
sich erst, wenn jemand losfährt.
Zwei neue Auslöser, beide klein: **einmal in `_nach_start()`** (dort kommt eine frisch
aktualisierte Integration an) und ein **Zehn-Minuten-Takt** (`TAKT_SCREENING`). Beides ist billig —
das Screening füllt nur leere Felder, und das Ortsbudget von acht Anfragen je Durchlauf
(`geokodierung.py`) bleibt weit unter Nominatims einer Anfrage je Sekunde. Nachgewiesen im
Testcontainer: Ortsfelder einer Fahrt geleert, ohne jede Fahrzeugbewegung vom Takt wieder gefüllt.
### Was an der gemeldeten Fahrt vom 01.09. NICHT zu reparieren ist
Dieselbe Fahrt in beiden Instanzen, gemessen:
| | reale Instanz | Testinstanz |
|---|---|---|
| Strecke | 7,0 km | 6,896 km |
| Dauer | 28 Min | 12,9 Min |
| Durchschnitt | 15,1 km/h | 32,6 km/h |
| Verbrauch | 11,4 l/100 km | 8,7 l/100 km |
Die Dauerdifferenz beträgt **15,1 Minuten** — der **Ignition-OFF-Timeout von 900 s** des FMM003
(Abschnitt AS). Die reale Instanz hat die Fahrt am 01.09. um 16:20 mit einer Fassung **vor**
`cef11b2`/`0124254` aufgezeichnet, die den Nachlauf noch mitzählte. Alles Weitere folgt daraus: der
Durchschnitt ist 7,0 km ÷ 28 Min, und der Verbrauch misst die Tankstandsdifferenz über ein 15
Minuten längeres Fenster.
**Ein Update repariert das nicht rückwirkend.** `ts_end` und `duration_s` stehen so in der
`fahrten.jsonl`, wie die damals laufende Fassung sie geschrieben hat; das Screening füllt nur leere
Felder und rechnet keine Fahrtgrenzen neu. Wer den Tag richtig haben will, muss ihn über den
Rückblick (`historienimport`) neu aufbauen.
### Nachtrag .9: kein Dauertakt, sondern Nachfassen
Der Zehn-Minuten-Takt aus .8 ist auf Rückfrage des Eigentümers wieder entfallen — zu Recht. Im
Normalbetrieb bringt er nichts: beim Fahrtende hat der Lauf das volle Budget von acht Anfragen, die
neue Fahrt braucht zwei, danach stehen Start und Ziel fest in der Fahrt und `_orte_screenen()` füllt
ohnehin nur leere Felder. Jeder weitere Durchlauf über dieselbe Fahrt ist Leerlauf.
Aufgebraucht wird das Budget in genau zwei Lagen: nach einem **Rückblick-Import**, der viele Fahrten
auf einmal anlegt, und **einmalig nach einem Update**, das ein neues Feld einführt. Dafür jetzt
`screening._nachfassen_planen()`: Ist am Ende eines Laufs das Ortsbudget leer **und** ist mindestens
ein Ort dazugekommen, läuft in 60 Sekunden ein weiterer. Beides muss gelten — Budget leer heißt „es
liegt noch Arbeit an", Fortschritt heißt „die Gegenstelle antwortet". Kommt ein Lauf mit übrigem
Budget durch, wird nichts eingeplant und die Kette endet von selbst; antwortet Nominatim gerade
nicht, endet sie ebenfalls, statt im Minutentakt gegen eine Wand zu laufen. `beenden()` bestellt
einen offenen Nachlauf ab.
`Geokodierung.neu` zählt dafür die in einem Lauf **gespeicherten** Ergebnisse — auch das ehrliche
„dort ist nichts", aber keinen Netzfehler, denn der wird bewusst nicht gemerkt.
**Live nachgewiesen** im Testcontainer: alle 13 Koordinaten und der Zwischenspeicher geleert, dann
Neustart. Lauf 1 löste genau 8 auf (12:49:5812:50:18), 60 s Pause, Lauf 2 die restlichen 5
(12:51:14) — danach keine weitere Zeile im Log. Ohne jede Fahrzeugbewegung.
### Nachtrag .9: die Draufsicht steht schmaler, das Tankdatum ist weg
Der Bildbereich auf der Fahrzeugseite lief randlos über die volle Breite (auf einem Telefon rund
374 px, mit 16:8 also 187 px hoch) und drückte die Sicherheitszeilen nach unten. Auf Ansage des
Eigentümers **schmaler statt flacher**: 60 % Breite, mittig, die Höhe geht im selben Verhältnis auf
rund 112 px zurück (`.szene.bleed.schmal` / `.dm-szene--schmal`). Flacher hätte bei einem Foto, das
nicht sehr breit ist, oben und unten geschnitten.
Aus der Kopfzeile des Langzeitverbrauchs ist zusätzlich das „bis 01.09.2026" verschwunden — der
letzte Rest der Herleitung, die aus dem Rumpf schon raus war.
### Nachtrag .9: zwei Gesten, und was davon es schon gab
**Zum Löschen nach links wischen, Räder → Archiv.** Der Löschknopf saß in der aufgeklappten Zeile,
hinter sieben Eingabefeldern. Das Panel hatte die Mechanik längst (`swipeZeile()`, seit den Fahrten-
und Tankvorgangslisten), nur war das Radarchiv nie angeschlossen — jetzt mit `rfarch:<id>` samt
eigenem `LOESCH_TEXT`/`LOESCH_HINWEIS` und einem Zweig in `eintragLoeschen()`. Umschlossen ist nur
die **Kopfzeile**, nicht der aufgeklappte Rumpf: sonst verschöbe ein waagerechtes Ziehen in einem
Eingabefeld die ganze Zeile.
Der App fehlte die Mechanik ganz; sie ist als `WischLoeschen` in `bausteine.tsx` nachgebaut.
Bewusst **nicht** die volle iOS-Mechanik: kein Löschen durch Durchwischen, die Geste legt nur den
Knopf frei, und die Sicherheitsabfrage bleibt — ein archivierter Satz ist unwiederbringlich. Der
Löschknopf in der aufgeklappten Zeile bleibt ebenfalls, am Rechner ist er der kürzere Weg.
**Vom linken Rand nach rechts wischen = zurück.** Auch das hatte das Panel schon
(`randwischenVerdrahten()`, 26 px Rand, 62 px Auslösung); der App fehlte es, dort war der Pfeil oben
links der einzige Weg zurück. `useWischZurueck()` in `Shell.tsx` bringt sie mit **denselben Zahlen**.
Zwei Dinge, die dabei nicht offensichtlich sind:
- Die App hat keinen Verlaufsstapel (siehe `navigation.ts`), und iOS' eigene Zurück-Geste hängt am
Stapel eines `UINavigationController`. In einer einzelnen WebView gibt es sie deshalb nicht
geschenkt — sie muss von Hand gebaut werden.
- Ziehen zum Aktualisieren und Randwischen greifen auf **dasselbe** `<main>`. Eine schräge Geste am
oberen Rand löste sonst beides gleichzeitig aus. Ein gemeinsames `gesteBelegt`-Ref entscheidet:
läuft der Randwisch, hält sich das Ziehen heraus.
Neu in **beiden**: die Seite folgt beim Randwischen dem Finger und federt unter der Schwelle zurück.
Vorher passierte bis zum Loslassen nichts Sichtbares — wer zu kurz zog, sah nicht einmal, dass er
die Geste begonnen hatte.
### Nachtrag .10: vier Befunde vom Bildschirmfoto des Eigentümers (02.09.2026)
**Das Zahnrad lag auf dem ersten Knopf.** Nachgerechnet an den Zahlen der Kachel: Innenabstand 22px,
Überschrift (`.ads-eyebrow`/`.label`) 12px hoch, danach folgt der erste Knopf ohne eigenen Abstand —
also ab y≈34. Das Zahnrad ist mit `top:11px` und 44px Trefferfläche von y=11 bis y=55 da. Es ragte
damit **21px in den Kachelinhalt** und fing die obere Hälfte von „Inland · <Nummer>" ab, sichtbar
wie funktional. Beide Oberflächen hatten dieselbe Geometrie, also denselben Fehler.
Nicht das Zahnrad verkleinert — 44px sind die Mindestgröße einer Trefferfläche —, sondern der
Kopfzeile die Höhe gegeben, die es braucht: `min-height:34px` und `padding-right:48px` auf der
ersten Überschrift einer Zahnrad-Kachel, Zahnrad auf `top/right:8px`. Damit endet es bei y=48, der
Inhalt beginnt bei y=56.
**Die Vorschau in den Einstellungen schnitt Fotos an.** Sie stand im festen Kasten (Panel 16:9, App
4:3) mit `object-fit:cover`; ein Foto anderen Formats verlor oben und unten je ein Stück, bei der
Frontansicht am deutlichsten. Wer dort prüft, ob das richtige Bild hochgeladen ist, muss es ganz
sehen — jetzt bestimmt das Bild die Höhe (`carfix klein` im Panel, `.dm-einstellbild` in der App),
dieselbe Entscheidung wie beim Fahrzeugfoto der Übersicht am 31.08.
**Der Titel einer Unterseite stand mittig**, als einziger Titel der ganzen Oberfläche — iOS' Regel
für Navigationsleisten, aber der Sprung fällt auf, wenn alles andere links steht. `text-align:center`
ist in beiden Oberflächen entfallen; der Zurück-Pfeil ist ein eigenes Flex-Element davor, der Titel
läuft ihm also nicht unter.
**Die Draufsicht ist freigestellt.** Das Foto lag auf einem gleichmäßigen Studiogrund (#DDDDDD) mit
Schatten — auf dunklem Grund ein heller Kasten mitten auf der Seite. Freigestellt mit einem
Flutfüller vom Bildrand her über „farblos und hell" (Sättigung ≤16, Helligkeit ≥150), zwei
Saumdurchgängen gegen den grauen Rand an der Kante, und anschließendem engen Zuschnitt:
1451×710 → **1405×618**, 15,7 % der Fläche durchsichtig. Der Zuschnitt ist der eigentliche Gewinn
für die Abstände — die kommen jetzt aus dem Layout (`padding: 22px 0 26px`), nicht aus leerem
Bildrand.
Das Werkzeug dafür steht **nicht** im Repo: es war ein einmaliger Lauf über ein Nutzerfoto (Skripte
im Scratchpad, ein PNG-Leser/-Schreiber auf `zlib` plus Flutfüller). Fotos sind Nutzerdaten und
liegen unter `/config/www/bilder/`, nie im Repository.
### Nachtrag .11: der zentrierte Titel war richtig, und das Zahnrad heißt settings-s
**Zurückgenommen: die Titel der Unterseiten stehen wieder mittig.** Die Änderung aus .10 („wie alle
anderen auch") war ein Fehlschluss. Apples HIG kennt **zwei** Titelformen, und beide sind hier im
Einsatz: den großen, linksbündigen auf oberster Ebene (Hauptbereiche) und den kleinen, **zentrierten**
in der Navigationsleiste einer Detailseite. Die „anderen" Titel waren die der Hauptbereiche — sie
folgen der anderen Regel. Nicht erneut ändern, ohne die beiden Formen auseinanderzuhalten; der
Kommentar an `.dm-kopf--unterseite` sagt das jetzt.
**Einheitlichkeit geprüft, nicht behauptet:** alle **21** Unterseiten der App haben einen Eintrag in
`ZURUECK` *und* in `TITEL` (Skript über `navigation.ts`, keine Lücke). Die Kopfzeile entsteht in
`Shell.tsx` aus `unterseite = !istHauptbereich(route.name)` — eine Seite kann also gar nicht
abweichen. Im Flächenlayout gibt es die Kopfzeile nicht, dort steht eine Brotkrume; der zentrierte
Titel erscheint nur auf dem Telefon, wo der Zurück-Pfeil immer da ist.
**Das Kachel-Zahnrad ist `settings-s` aus der Audi-Icon-Bibliothek** (24er-Raster) — nachgewiesen,
nicht angenommen: der Pfad in `SymbolZahnradVoll` ist zeichengleich mit
`design/assets/icons/settings.svg`. Im Panel stand derselbe Pfad **sechsmal wortgleich im Markup**,
obwohl es für genau diesen Zweck den `CI`-Satz gibt; er ist jetzt `CI.settingsS` und wird über
`ciSVG(CI.settingsS, 17)` gesetzt. Die 48er-Variante hieße `settings-l` und wird nirgends gebraucht,
das Zahnrad steht immer auf 17px.
**Offen und bewusst nicht angefasst:** `SymbolZahnrad` (Seitenleiste des Flächenlayouts) ist ein
selbst gezeichnetes Kontursymbol und stammt **nicht** aus dem CI-Satz. Es auf `settings-s`
umzustellen würde es neben die übrigen Leistensymbole stellen, die alle Kontur sind — das CI-Symbol
ist flächiger und wöge dort schwerer. Entscheidung des Eigentümers.
### Nachtrag .12: das gezeichnete Zahnrad ist weg, die Zapfsäule ist fuel-station-s
**Das gezeichnete Zahnrad war noch in Gebrauch** — nur nicht dort, wo man hinsieht. In der App
stand es in der Seitenleiste des Flächenlayouts (`Shell.tsx`), im Panel als `.profilbtn` in der
Kopfzeile, per CSS auf Telefonbreite `display:none` und erst ab dem großen Umbruch `inline-flex`.
Auf einem Telefon also nie sichtbar — daher der Eindruck, es sei längst raus. Beide zeigen jetzt
`settings-s`, `SymbolZahnrad` ist gelöscht. Im Panel musste die Regel `.profilbtn svg` mit: sie
stand auf `fill:none; stroke:currentColor`, was um jede Fläche des CI-Symbols eine Kontur gezogen
hätte — dieselbe Falle, die `.voll`/`RahmenFlaeche` in der App vermeidet.
**Die Zapfsäule des Tanken-Reiters ist jetzt `fuel-station-s`** (aus der Bibliothek des Eigentümers
nachgereicht; im Repo lag sie nicht — `design/assets/icons/gas-station.svg` ist eine andere
Variante mit Buchstaben unter der Säule). Damit ist von den fünf Reitersymbolen nur noch
„Übersicht" gezeichnet.
**Größe live gemessen statt geschätzt** (`getBBox()` im Browser, wie schon bei `polls-s`):
| Symbol | Breite | Höhe |
|---|---|---|
| Übersicht | 21,78 | 12,46 |
| Mein Audi | 24,00 | 17,22 |
| Fahrten | 22,00 | 21,44 |
| Statistik (CI, Faktor 0,77) | 16,94 | 18,48 |
| Tanken, gezeichnet (vorher) | 14,00 | 15,50 |
| **fuel-station-s roh** | **19,00** | **22,00** |
| **fuel-station-s, Faktor 0,84** | **15,96** | **18,48** |
Roh wäre es das höchste Symbol der Leiste gewesen — höher als „Fahrten". Faktor **0,84** um die
Rastermitte (`translate(1.5,1.92) scale(0.84)`) bringt es auf **genau dieselbe senkrechte Spanne wie
Statistik**: y 2,76 bis 21,24. Ränder nachgemessen, nicht nur die Größe — die Lehre aus dem
Übersicht-Symbol vom selben Tag.
`fuel-station-l` (48er-Raster) wird nicht gebraucht: die Reiter zeichnen mit 21 bzw. 26px.
+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
@@ -1 +1 @@
{"version":"2026.9.2.7","sha256":"55a0da035cc4143bf5a52fb02112f5d89cec606b73a63a1911e1a56f6b63df38","bytes":265969,"gebaut":"2026-09-02T10:27:19Z"}
{"version":"2026.9.2.12","sha256":"2d28d29f526a130188efe816fc5cb8054f3f1afaf86f61eb371dc617753de29c","bytes":266536,"gebaut":"2026-09-02T13:40:40Z"}
@@ -340,6 +340,11 @@ const BILDER = [
{ name: "Cockpit", label: "Innenraum Cockpit", datei: "cockpit.webp" },
{ name: "Scheinwerfer", label: "Scheinwerfer", datei: "scheinwerfer.webp" },
{ name: "Sitze", label: "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", label: "Auto von oben", datei: "draufsicht.webp" },
];
let bild = 0;
const SEITE_WINTER = { label: "Auto Seite mit Winterrädern", datei: "seitenansicht-winter.webp" };
@@ -1274,6 +1279,16 @@ function standortKachel() {
/* Symbole aus dem Audi-CI-Satz (assets/icons/) - hier eingebettet, weil die
Panel-Datei ohne zusaetzliche Abrufe auskommen soll. */
const CI = {
/* fuel-station-s aus dem Audi-CI-Satz (24er-Raster) - die Zapfsaeule des
Tanken-Reiters. Ersetzt seit dem 02.09.2026 eine handgezeichnete Saeule.
Die 48er-Variante hiesse fuel-station-l und wird nicht gebraucht: die
Reiter zeichnen mit 21 bzw. 26px, also im 24er-Raster. */
fuelStationS: '<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"/>',
/* settings-s aus dem Audi-CI-Satz (24er-Raster) - das Zahnrad in der
rechten oberen Ecke einer Kachel. Stand bis zum 02.09.2026 sechsmal
wortgleich im Markup; die 48er-Variante hiesse settings-l und wird hier
nirgends gebraucht, das Zahnrad wird immer 17px dargestellt. */
settingsS: '<path fill="currentColor" d="M9.4 2h4.2v2.84c.37.13.75.27 1.11.44L16.7 3.3l3 3.01-1.98 1.99c.17.36.31.74.44 1.11H21v4.3h-2.84c-.13.37-.27.75-.44 1.11l2 2-3.03 2.89-1.98-1.98c-.36.17-.74.31-1.11.44V21H9.4v-2.84a9 9 0 0 1-1.11-.44L6.3 19.7l-3-3.01 1.98-1.99a9 9 0 0 1-.44-1.11H2V9.4h2.84c.13-.37.27-.75.44-1.11L3.3 6.3l3.01-3L8.3 5.28c.36-.17.74-.31 1.11-.44V2Zm1 1v2.56l-.34.11c-.61.2-1.16.4-1.6.66l-.34.2L6.3 4.71 4.7 6.3l1.83 1.82-.2.34c-.27.44-.45.99-.66 1.6l-.11.34H3v2.2h2.56l.11.34c.2.61.4 1.16.66 1.6l.2.34-1.82 1.82 1.59 1.6 1.82-1.83.34.2c.44.27.99.45 1.6.66l.34.11V20h2.2v-2.56l.34-.11a8.3 8.3 0 0 0 1.6-.66l.34-.2 1.83 1.83 1.57-1.5-1.81-1.82.2-.34c.27-.44.45-.99.66-1.6l.11-.34H20v-2.3h-2.56l-.11-.34a8.2 8.2 0 0 0-.66-1.6l-.2-.34 1.82-1.82-1.59-1.6-1.82 1.83-.34-.2a8.2 8.2 0 0 0-1.6-.66l-.34-.11V3zm-2.9 8.5c0-2.18 1.82-4 4-4s4 1.82 4 4-1.82 4-4 4-4-1.82-4-4m4-3c-1.62 0-3 1.38-3 3s1.38 3 3 3 3-1.38 3-3-1.38-3-3-3"/>',
mapLayer: '<path fill="currentColor" d="m11.7 19.4-8.97-5.66.54-.85 8.96 5.67c.17.1.37.1.54 0l8.96-5.67.54.85-8.97 5.66a1.5 1.5 0 0 1-1.6 0m.53-3.84c.17.1.37.1.54 0l8.3-5.25-8.3-5.23a.5.5 0 0 0-.54 0l-8.3 5.23zm-.53.84-9.64-6.09 9.64-6.08a1.5 1.5 0 0 1 1.6 0l9.64 6.08-9.64 6.1c-.49.3-1.11.3-1.6 0Z"/>',
carSearch: '<path fill="currentColor" d="M1 7.8a6.16 6.16 0 0 0 6.1 6.1h.4A5.7 5.7 0 0 0 13 7.91V7.9a6.1 6.1 0 0 0-5.91-6A5.93 5.93 0 0 0 1 7.8m-1 0C0 3.92 3.23.8 7.11.9a7.1 7.1 0 0 1 6.89 7c.06 2.33-1 4.44-2.73 5.71L14.8 19H20v1.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5v-7.8l-1.9-1.9-.04-.06a55 55 0 0 0-2.84-4.71c-1.42-.1-2.49-.17-3.2-.2l.04-1c.77.04 1.93.1 3.48.22l.24.02.14.2a55 55 0 0 1 2.99 4.93L24 12.3v8.2c0 .83-.67 1.5-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5V20h-3.55l1.67 2.54-.84.54L14.26 20 5 19.96v.54c0 .83-.67 1.5-1.5 1.5h-2A1.5 1.5 0 0 1 0 20.5v-7.08h1v7.08c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5v-1.54l9.6.02-3.17-4.83c-.87.45-1.85.72-2.92.75H7.1c-3.88 0-7-3.13-7.1-7.09zm5.47-2.75c1.86-.12 3.37-.2 4.52-.24l.03 1c-1.08.03-2.5.1-4.24.22L4.51 8c-.27.43-.6 1-1 1.7l-.87-.48c.4-.72.74-1.3 1.03-1.76q.6-.96 1.41-2.19l.14-.2z"/>',
gps: '<path fill="currentColor" d="M12.5 3.51V6.5h-1V3.51a8.46 8.46 0 0 0-7.99 7.99H6.5v1H3.51a8.46 8.46 0 0 0 7.99 7.99V17.5h1v2.99a8.46 8.46 0 0 0 7.99-7.99H17.5v-1h2.99a8.46 8.46 0 0 0-7.99-7.99m0-1a9.46 9.46 0 0 1 8.99 8.99h2.01v1h-2.01a9.46 9.46 0 0 1-8.99 8.99v2.01h-1v-2.01a9.46 9.46 0 0 1-8.99-8.99H.5v-1h2.01a9.46 9.46 0 0 1 8.99-8.99V.5h1z"/>',
@@ -1486,7 +1501,7 @@ function teaser() {
<span class="v">${streckeText(t.distance_km, "—")}<small>${fahrtVerbrauch(t) != null ? de(fahrtVerbrauch(t), 1) + " l/100 km" : "—"}</small></span>
<svg class="chev" viewBox="0 0 6 10"><polyline points="1,1 5,5 1,9"/></svg></button>` : ""}
${f ? `<button class="leaf" style="padding-left:0;border-bottom:0" data-go="fill:${f.tank_id}">
<span class="k">Letzter Tankvorgang<small>${esc(f.station_name || "")}</small></span>
<span class="k">Letzter Tankvorgang<small>${f.ts ? dedat(new Date(f.ts)) : ""}</small></span>
<span class="v">${f.liters != null ? de(f.liters, 2) + " l" : ""}<small>${f.fuel_total_eur != null ? eur(f.fuel_total_eur) + " €" : ""}</small></span>
<svg class="chev" viewBox="0 0 6 10"><polyline points="1,1 5,5 1,9"/></svg></button>` : ""}</div>`;
}
@@ -1848,7 +1863,7 @@ function vSicherheit() {
/* C6: Draufsicht oben, grüne Sammelmeldung, dann die vier Sammelzeilen -
dieselbe Reihenfolge wie der Fahrzeugstatus in der myAudi-App. */
return `
<div class="szene bleed"><div class="carbox">${bildMitPlatzhalter(`/local/bilder/draufsicht.webp?v=${bildVersion}`, "draufsicht.webp", "Draufsicht", "carfix klein")}</div></div>
<div class="szene bleed schmal"><div class="carbox">${bildMitPlatzhalter(`/local/bilder/draufsicht.webp?v=${bildVersion}`, "draufsicht.webp", "Draufsicht", "carfix klein")}</div></div>
<div class="tile" style="display:flex;align-items:center;gap:12px;padding:16px 20px">
${statusKreis(g)}
<span style="font-size:15px;color:var(--fg)">${esc(sammelText)}</span>
@@ -2404,7 +2419,7 @@ function vService() {
<div class="tile">
<span class="label">Autohaus</span>
<button class="zahnrad" data-go="werkstatt:x" aria-label="Angaben ändern">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9.4 2h4.2v2.84c.37.13.75.27 1.11.44L16.7 3.3l3 3.01-1.98 1.99c.17.36.31.74.44 1.11H21v4.3h-2.84c-.13.37-.27.75-.44 1.11l2 2-3.03 2.89-1.98-1.98c-.36.17-.74.31-1.11.44V21H9.4v-2.84a9 9 0 0 1-1.11-.44L6.3 19.7l-3-3.01 1.98-1.99a9 9 0 0 1-.44-1.11H2V9.4h2.84c.13-.37.27-.75.44-1.11L3.3 6.3l3.01-3L8.3 5.28c.36-.17.74-.31 1.11-.44V2Zm1 1v2.56l-.34.11c-.61.2-1.16.4-1.6.66l-.34.2L6.3 4.71 4.7 6.3l1.83 1.82-.2.34c-.27.44-.45.99-.66 1.6l-.11.34H3v2.2h2.56l.11.34c.2.61.4 1.16.66 1.6l.2.34-1.82 1.82 1.59 1.6 1.82-1.83.34.2c.44.27.99.45 1.6.66l.34.11V20h2.2v-2.56l.34-.11a8.3 8.3 0 0 0 1.6-.66l.34-.2 1.83 1.83 1.57-1.5-1.81-1.82.2-.34c.27-.44.45-.99.66-1.6l.11-.34H20v-2.3h-2.56l-.11-.34a8.2 8.2 0 0 0-.66-1.6l-.2-.34 1.82-1.82-1.59-1.6-1.82 1.83-.34-.2a8.2 8.2 0 0 0-1.6-.66l-.34-.11V3zm-2.9 8.5c0-2.18 1.82-4 4-4s4 1.82 4 4-1.82 4-4 4-4-1.82-4-4m4-3c-1.62 0-3 1.38-3 3s1.38 3 3 3 3-1.38 3-3-1.38-3-3-3"/></svg>
${ciSVG(CI.settingsS, 17)}
</button>
<div style="font-size:15px;margin-top:12px">${esc(sv.autohaus.name)}</div>
<span class="label" style="margin-top:8px">${esc(sv.autohaus.adresse)}</span>
@@ -2655,7 +2670,7 @@ function vVers() {
return `
<div class="tile"><span class="label">Versicherung - Schaden Notruf 24/7</span>
<button class="zahnrad" data-go="notruf:x" aria-label="Rufnummern ändern">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9.4 2h4.2v2.84c.37.13.75.27 1.11.44L16.7 3.3l3 3.01-1.98 1.99c.17.36.31.74.44 1.11H21v4.3h-2.84c-.13.37-.27.75-.44 1.11l2 2-3.03 2.89-1.98-1.98c-.36.17-.74.31-1.11.44V21H9.4v-2.84a9 9 0 0 1-1.11-.44L6.3 19.7l-3-3.01 1.98-1.99a9 9 0 0 1-.44-1.11H2V9.4h2.84c.13-.37.27-.75.44-1.11L3.3 6.3l3.01-3L8.3 5.28c.36-.17.74-.31 1.11-.44V2Zm1 1v2.56l-.34.11c-.61.2-1.16.4-1.6.66l-.34.2L6.3 4.71 4.7 6.3l1.83 1.82-.2.34c-.27.44-.45.99-.66 1.6l-.11.34H3v2.2h2.56l.11.34c.2.61.4 1.16.66 1.6l.2.34-1.82 1.82 1.59 1.6 1.82-1.83.34.2c.44.27.99.45 1.6.66l.34.11V20h2.2v-2.56l.34-.11a8.3 8.3 0 0 0 1.6-.66l.34-.2 1.83 1.83 1.57-1.5-1.81-1.82.2-.34c.27-.44.45-.99.66-1.6l.11-.34H20v-2.3h-2.56l-.11-.34a8.2 8.2 0 0 0-.66-1.6l-.2-.34 1.82-1.82-1.59-1.6-1.82 1.83-.34-.2a8.2 8.2 0 0 0-1.6-.66l-.34-.11V3zm-2.9 8.5c0-2.18 1.82-4 4-4s4 1.82 4 4-1.82 4-4 4-4-1.82-4-4m4-3c-1.62 0-3 1.38-3 3s1.38 3 3 3 3-1.38 3-3-1.38-3-3-3"/></svg>
${ciSVG(CI.settingsS, 17)}
</button>
<a class="aktion" href="tel:${esc(v.service.replace(/\s/g, ""))}"
style="display:block;text-decoration:none;margin-top:14px;text-align:center">
@@ -2666,7 +2681,7 @@ function vVers() {
</div>
<div class="tile"><span class="label">Jahresbeitrag ab ${dedat(new Date(v.gueltigAb))}</span>
<button class="zahnrad" data-go="beitrag:x" aria-label="Beitrag anpassen">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9.4 2h4.2v2.84c.37.13.75.27 1.11.44L16.7 3.3l3 3.01-1.98 1.99c.17.36.31.74.44 1.11H21v4.3h-2.84c-.13.37-.27.75-.44 1.11l2 2-3.03 2.89-1.98-1.98c-.36.17-.74.31-1.11.44V21H9.4v-2.84a9 9 0 0 1-1.11-.44L6.3 19.7l-3-3.01 1.98-1.99a9 9 0 0 1-.44-1.11H2V9.4h2.84c.13-.37.27-.75.44-1.11L3.3 6.3l3.01-3L8.3 5.28c.36-.17.74-.31 1.11-.44V2Zm1 1v2.56l-.34.11c-.61.2-1.16.4-1.6.66l-.34.2L6.3 4.71 4.7 6.3l1.83 1.82-.2.34c-.27.44-.45.99-.66 1.6l-.11.34H3v2.2h2.56l.11.34c.2.61.4 1.16.66 1.6l.2.34-1.82 1.82 1.59 1.6 1.82-1.83.34.2c.44.27.99.45 1.6.66l.34.11V20h2.2v-2.56l.34-.11a8.3 8.3 0 0 0 1.6-.66l.34-.2 1.83 1.83 1.57-1.5-1.81-1.82.2-.34c.27-.44.45-.99.66-1.6l.11-.34H20v-2.3h-2.56l-.11-.34a8.2 8.2 0 0 0-.66-1.6l-.2-.34 1.82-1.82-1.59-1.6-1.82 1.83-.34-.2a8.2 8.2 0 0 0-1.6-.66l-.34-.11V3zm-2.9 8.5c0-2.18 1.82-4 4-4s4 1.82 4 4-1.82 4-4 4-4-1.82-4-4m4-3c-1.62 0-3 1.38-3 3s1.38 3 3 3 3-1.38 3-3-1.38-3-3-3"/></svg>
${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>
@@ -2681,7 +2696,7 @@ function vVers() {
</div>
<div class="tile"><span class="label">Vertrag</span>
<button class="zahnrad" data-go="vertrag:x" aria-label="Vertrag bearbeiten">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9.4 2h4.2v2.84c.37.13.75.27 1.11.44L16.7 3.3l3 3.01-1.98 1.99c.17.36.31.74.44 1.11H21v4.3h-2.84c-.13.37-.27.75-.44 1.11l2 2-3.03 2.89-1.98-1.98c-.36.17-.74.31-1.11.44V21H9.4v-2.84a9 9 0 0 1-1.11-.44L6.3 19.7l-3-3.01 1.98-1.99a9 9 0 0 1-.44-1.11H2V9.4h2.84c.13-.37.27-.75.44-1.11L3.3 6.3l3.01-3L8.3 5.28c.36-.17.74-.31 1.11-.44V2Zm1 1v2.56l-.34.11c-.61.2-1.16.4-1.6.66l-.34.2L6.3 4.71 4.7 6.3l1.83 1.82-.2.34c-.27.44-.45.99-.66 1.6l-.11.34H3v2.2h2.56l.11.34c.2.61.4 1.16.66 1.6l.2.34-1.82 1.82 1.59 1.6 1.82-1.83.34.2c.44.27.99.45 1.6.66l.34.11V20h2.2v-2.56l.34-.11a8.3 8.3 0 0 0 1.6-.66l.34-.2 1.83 1.83 1.57-1.5-1.81-1.82.2-.34c.27-.44.45-.99.66-1.6l.11-.34H20v-2.3h-2.56l-.11-.34a8.2 8.2 0 0 0-.66-1.6l-.2-.34 1.82-1.82-1.59-1.6-1.82 1.83-.34-.2a8.2 8.2 0 0 0-1.6-.66l-.34-.11V3zm-2.9 8.5c0-2.18 1.82-4 4-4s4 1.82 4 4-1.82 4-4 4-4-1.82-4-4m4-3c-1.62 0-3 1.38-3 3s1.38 3 3 3 3-1.38 3-3-1.38-3-3-3"/></svg>
${ciSVG(CI.settingsS, 17)}
</button>
<dl class="rows" style="margin-top:8px">
<div class="row"><dt>Gesellschaft</dt><dd>${esc(v.gesellschaft)}</dd></div>
@@ -2827,7 +2842,7 @@ function vSteuer() {
return `
<div class="tile" style="margin-top:26px"><span class="label">Kfz-Steuer</span>
<button class="zahnrad" data-go="steuer:x" aria-label="Kfz-Steuer bearbeiten">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9.4 2h4.2v2.84c.37.13.75.27 1.11.44L16.7 3.3l3 3.01-1.98 1.99c.17.36.31.74.44 1.11H21v4.3h-2.84c-.13.37-.27.75-.44 1.11l2 2-3.03 2.89-1.98-1.98c-.36.17-.74.31-1.11.44V21H9.4v-2.84a9 9 0 0 1-1.11-.44L6.3 19.7l-3-3.01 1.98-1.99a9 9 0 0 1-.44-1.11H2V9.4h2.84c.13-.37.27-.75.44-1.11L3.3 6.3l3.01-3L8.3 5.28c.36-.17.74-.31 1.11-.44V2Zm1 1v2.56l-.34.11c-.61.2-1.16.4-1.6.66l-.34.2L6.3 4.71 4.7 6.3l1.83 1.82-.2.34c-.27.44-.45.99-.66 1.6l-.11.34H3v2.2h2.56l.11.34c.2.61.4 1.16.66 1.6l.2.34-1.82 1.82 1.59 1.6 1.82-1.83.34.2c.44.27.99.45 1.6.66l.34.11V20h2.2v-2.56l.34-.11a8.3 8.3 0 0 0 1.6-.66l.34-.2 1.83 1.83 1.57-1.5-1.81-1.82.2-.34c.27-.44.45-.99.66-1.6l.11-.34H20v-2.3h-2.56l-.11-.34a8.2 8.2 0 0 0-.66-1.6l-.2-.34 1.82-1.82-1.59-1.6-1.82 1.83-.34-.2a8.2 8.2 0 0 0-1.6-.66l-.34-.11V3zm-2.9 8.5c0-2.18 1.82-4 4-4s4 1.82 4 4-1.82 4-4 4-4-1.82-4-4m4-3c-1.62 0-3 1.38-3 3s1.38 3 3 3 3-1.38 3-3-1.38-3-3-3"/></svg>
${ciSVG(CI.settingsS, 17)}
</button>
<dl class="rows" style="margin-top:10px">
<div class="row"><dt>Betrag</dt><dd>${eur(t.betrag)} <small>${t.zeitraum === "jährlich" ? "im Jahr" : esc(t.zeitraum)}</small></dd></div>
@@ -2937,7 +2952,10 @@ function vEinst() {
const info = bildInfo(startIndex());
return `<div style="margin-top:12px">
<div data-bildklick="${esc(info.datei)}">
${bildMitPlatzhalter(info.src, info.datei, info.label, "carfix", null, true)}
${/* "klein" = das Bild bestimmt die Hoehe, statt im 16:9-Kasten
beschnitten zu werden (siehe .carfix.klein). Wer hier prueft,
ob das richtige Foto hochgeladen ist, muss es ganz sehen. */""
}${bildMitPlatzhalter(info.src, info.datei, info.label, "carfix klein", null, true)}
</div>
<input type="file" accept="image/*" hidden data-bildupload="${esc(info.datei)}">
${bildMenuOffen === info.datei ? `
@@ -3399,7 +3417,7 @@ function vReifen() {
return `
<div class="tile"><span class="label">Anzugsmoment der Radschrauben</span>
<button class="zahnrad" data-nmauf aria-label="Anzugsmoment ändern">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9.4 2h4.2v2.84c.37.13.75.27 1.11.44L16.7 3.3l3 3.01-1.98 1.99c.17.36.31.74.44 1.11H21v4.3h-2.84c-.13.37-.27.75-.44 1.11l2 2-3.03 2.89-1.98-1.98c-.36.17-.74.31-1.11.44V21H9.4v-2.84a9 9 0 0 1-1.11-.44L6.3 19.7l-3-3.01 1.98-1.99a9 9 0 0 1-.44-1.11H2V9.4h2.84c.13-.37.27-.75.44-1.11L3.3 6.3l3.01-3L8.3 5.28c.36-.17.74-.31 1.11-.44V2Zm1 1v2.56l-.34.11c-.61.2-1.16.4-1.6.66l-.34.2L6.3 4.71 4.7 6.3l1.83 1.82-.2.34c-.27.44-.45.99-.66 1.6l-.11.34H3v2.2h2.56l.11.34c.2.61.4 1.16.66 1.6l.2.34-1.82 1.82 1.59 1.6 1.82-1.83.34.2c.44.27.99.45 1.6.66l.34.11V20h2.2v-2.56l.34-.11a8.3 8.3 0 0 0 1.6-.66l.34-.2 1.83 1.83 1.57-1.5-1.81-1.82.2-.34c.27-.44.45-.99.66-1.6l.11-.34H20v-2.3h-2.56l-.11-.34a8.2 8.2 0 0 0-.66-1.6l-.2-.34 1.82-1.82-1.59-1.6-1.82 1.83-.34-.2a8.2 8.2 0 0 0-1.6-.66l-.34-.11V3zm-2.9 8.5c0-2.18 1.82-4 4-4s4 1.82 4 4-1.82 4-4 4-4-1.82-4-4m4-3c-1.62 0-3 1.38-3 3s1.38 3 3 3 3-1.38 3-3-1.38-3-3-3"/></svg>
${ciSVG(CI.settingsS, 17)}
</button>
<div class="fig" style="font-size:34px;margin-top:12px">${de(r.anzugsmoment)}<span class="unit">Nm</span></div>
${nmOffen ? `
@@ -3438,12 +3456,17 @@ function vReifenArchiv() {
return `<div class="tile"><span class="label">Archiv</span>
${archiv.map((a, i) => {
const satzLabel = a.satz === "winter" ? "Winterräder" : "Sommerräder";
return `<button class="acc-h" aria-expanded="false" data-acc="rfarch${i}"><span class="mark"></span>
/* Nach links wischen zum Löschen, wie in der Fahrten- und
Tankvorgangsliste. Der Löschknopf in der aufgeklappten Zeile bleibt:
am Rechner ist er der kürzere Weg. Umschlossen ist nur die
Kopfzeile, nicht der aufgeklappte Rumpf - sonst verschöbe ein
waagerechtes Ziehen in einem Eingabefeld die ganze Zeile. */
return swipeZeile(`rfarch:${a.id}`, `<button class="acc-h" aria-expanded="false" data-acc="rfarch${i}"><span class="mark"></span>
<span class="k">${satzLabel}</span>
<span class="v" style="display:flex;flex-direction:column;align-items:flex-end;gap:3px">
<span style="color:var(--fg);font-size:15px">${a.km != null ? de(a.km) + " km" : ""}</span>
<small>Ersetzt am ${dedat(new Date(a.ersetzt_am))}</small>
</span></button>
</span></button>`) + `
<div class="acc-b" id="rfarch${i}">
<div class="feld"><label>Ersetzt am</label><input type="date" value="${esc(a.ersetzt_am ?? "")}" data-rfa="${a.id}|ersetzt_am"></div>
<div class="feld"><label>Gefahrene Strecke</label>
@@ -3682,18 +3705,24 @@ function statTile(id, label, fig, unit, extra, body) {
km im selben Zeitraum), da einzelne Fahrten keinen eigenen Verbrauchswert
tragen - erst über einen Zeitraum gemittelt ergibt das einen sinnvollen
l/100km-Wert. */
/* 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.
Gleichlautend in companion-app/src/daten/statistik.ts (ZEITRAUM_TAGE). */
const ZEITRAUM_TAGE = { jahr: 365, monat: 30, woche: 7, tag: 1 };
function periodStart(art) {
// Alles, was je erfasst wurde - kein Zeitraum, sondern die Summe.
if (art === "absolut") return new Date(0);
const jetzt = new Date();
if (art === "jahr") return new Date(jetzt.getFullYear(), 0, 1);
if (art === "monat") return new Date(jetzt.getFullYear(), jetzt.getMonth(), 1);
if (art === "woche") {
const d = new Date(jetzt); d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() - ((d.getDay() + 6) % 7));
return d;
}
const d = new Date(jetzt); d.setHours(0, 0, 0, 0); return d;
return new Date(Date.now() - ZEITRAUM_TAGE[art] * 24 * 60 * 60 * 1000);
}
const fahrtenSeit = (art) => TRIPS.filter((t) => t.distance_km != null && new Date(t.ts_start) >= periodStart(art));
const tankSeit = (art) => FILLS.filter((f) => f.liters != null && new Date(f.ts) >= periodStart(art));
@@ -3797,6 +3826,12 @@ function vStat() {
const nachtKm = kmJahr - tagKm;
const arbeitFahrten = fJahr.filter((t) => t.art === "arbeitsweg"), privatFahrten = fJahr.filter((t) => t.art !== "arbeitsweg");
const arbeitKm = sum(arbeitFahrten, (t) => t.distance_km), privatKm = kmJahr - arbeitKm;
/* 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 lv.liter/lv.km/lv.basis nach wie vor. In der Kachel bleiben
der Wert selbst und die Näherung je Zeitraum. */
const lv = langzeitverbrauch();
return `
${statTile("s1", "Distanz und Fahrzeit", de(kmAbs), "km", de(hAbs) + " h",
@@ -3805,15 +3840,8 @@ function vStat() {
${statTile("s2", "Getankte Menge und Kosten", de(lAbs), "l", de(eAbs) + " €",
quad("Getankte Liter [l]", [[de(lJahr), "Jahr"], [de(sum(tankSeit("monat"), (f) => f.liters)), "Monat"], [de(sum(tankSeit("woche"), (f) => f.liters)), "Woche"], [de(sum(tankSeit("tag"), (f) => f.liters)), "Tag"]]) +
quad("Kraftstoffkosten [€]", [[de(eJahr), "Jahr"], [de(sum(tankSeit("monat"), (f) => f.fuel_total_eur || 0)), "Monat"], [de(sum(tankSeit("woche"), (f) => f.fuel_total_eur || 0)), "Woche"], [de(sum(tankSeit("tag"), (f) => f.fuel_total_eur || 0)), "Tag"]]))}
${statTile("s3", "Langzeitverbrauch", lv.wert, "l/100 km", lv.bis ? "bis " + dedat(lv.bis) : "",
`<dl class="rows">
<div class="row"><dt>Getankt insgesamt</dt><dd>${de(lv.liter, 1)} l</dd></div>
<div class="row"><dt>Strecke bis zum letzten Tankstop</dt><dd>${de(lv.km)} km<small>${lv.basis}</small></dd></div>
<div class="row" style="border-bottom:0"><dt>Letzter Tankstop</dt><dd>${lv.bis ? dedat(lv.bis) : ""}</dd></div>
</dl>
${lv.unplausibel ? `<span class="label" style="margin-top:16px;color:var(--warn)">Aus Litern und Strecke ergibt sich derzeit kein plausibler Wert. Sobald Tankvorgänge und Kilometerstände vollständig vorliegen, erscheint er hier.</span>` : ""}
${quad("Näherung je Fahrt, je Zeitraum [l/100 km]", [[schnittFahrtVerbrauch(fJahr), "Jahr"], [schnittFahrtVerbrauch(fMonat), "Monat"], [schnittFahrtVerbrauch(fWoche), "Woche"], [schnittFahrtVerbrauch(fTag), "Tag"]])}
<span class="label" style="margin-top: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>`)}
${statTile("s3", "Langzeitverbrauch", lv.wert, "l/100 km", "",
quad("Näherung je Fahrt, je Zeitraum [l/100 km]", [[schnittFahrtVerbrauch(fJahr), "Jahr"], [schnittFahrtVerbrauch(fMonat), "Monat"], [schnittFahrtVerbrauch(fWoche), "Woche"], [schnittFahrtVerbrauch(fTag), "Tag"]]))}
${statTile("s4", "Tag und Nacht", kmJahr > 0 ? de(Math.round((tagKm / kmJahr) * 100)) : "", "% bei Tag", de(tagKm) + " km",
`<div class="bar"><i style="flex:${tagKm};background:var(--fg)"></i><i style="flex:${nachtKm};background:var(--fg3)"></i></div>
<div class="legend"><span><i style="background:var(--fg)"></i>Tag · ${de(tagKm)} km</span>
@@ -3891,6 +3919,7 @@ const LOESCH_TEXT = {
sbuch: "Wartungsplan-Eintrag löschen?",
bild: "Foto löschen?",
batt: "Messwert löschen?",
rfarch: "Archivierten Radsatz löschen?",
};
const LOESCH_HINWEIS = {
trip: "Die Fahrt wird aus dem Fahrtenbuch entfernt. Das lässt sich nicht widerrufen.",
@@ -3898,6 +3927,7 @@ const LOESCH_HINWEIS = {
sbuch: "Der Eintrag wird aus dem Wartungsplan entfernt. Das lässt sich nicht widerrufen.",
bild: "Das Foto wird vom Server gelöscht. Der Platz bleibt bestehen und kann neu belegt werden.",
batt: "Der Tageseintrag wird aus der Messwertliste entfernt. Das lässt sich nicht widerrufen.",
rfarch: "Der archivierte Radsatz wird mit seiner Laufleistung entfernt. Das lässt sich nicht widerrufen.",
};
function eintragLoeschen(art, id) {
if (art === "trip") {
@@ -3912,6 +3942,11 @@ function eintragLoeschen(art, id) {
} else if (art === "batt") {
serviceRufen("batterieverlauf_loeschen", { datum: id });
BATTERIEVERLAUF = BATTERIEVERLAUF.filter((e) => e.datum !== id);
} else if (art === "rfarch") {
// Eigener Dienst statt profilSpeichern - wie schon beim Löschknopf in der
// aufgeklappten Zeile (siehe data-reifenarchivloeschen).
serviceRufen("reifen_archiv_loeschen", { id });
CAR.reifen.archiv = (CAR.reifen.archiv || []).filter((a) => a.id !== id);
}
}
@@ -4221,7 +4256,15 @@ const ICONS = {
(.tab svg 26px/22px) automatisch im richtigen Verhältnis, ohne zwei
Stellen pflegen zu müssen. */
stat: `<g transform="translate(2.76,2.76) scale(0.77)">${CI.pollsS}</g>`,
fuel: '<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"/>',
/* Dieselbe Behandlung wie "stat" oben, aus demselben Grund: das CI-Symbol
ist randlos gezeichnet und stuende ungeskaliert mit 22 von 24 Einheiten
Hoehe als hoechstes Symbol der Leiste da - hoeher als "Fahrten" (21,44)
und deutlich hoeher als die gezeichnete Saeule davor (15,50).
Faktor 0,84 um die Rastermitte bringt es auf 15,96 x 18,48 und damit auf
exakt dieselbe senkrechte Spanne wie "stat" (y 2,76 bis 21,24). Live
gemessen per getBBox(), Raender eingeschlossen - nicht geschaetzt. */
fuel: `<g transform="translate(1.5,1.92) scale(0.84)">${CI.fuelStationS}</g>`,
};
const TABS = [
{ id: "home", label: "Übersicht", eyebrow: "", title: "Übersicht" },
@@ -4231,7 +4274,7 @@ const TABS = [
{ id: "fuel", label: "Tanken", eyebrow: "", title: "Tanken" },
];
/* Symbole, die als Flaeche gezeichnet sind statt als Kontur */
const ICONS_VOLL = new Set(["audi", "stat", "trips"]);
const ICONS_VOLL = new Set(["audi", "stat", "trips", "fuel"]);
let route = { name: "home" };
const ZURUECK = {
trip: "trips", fill: "fuel", service: "audi", reifen: "audi", ident: "audi",
@@ -4895,8 +4938,22 @@ function zurueck() {
route = ziel; render();
}
/* B3: Randwischen zurück - dieselbe Bewegung wie ein Tipp auf den Pfeil. */
/* B3: Randwischen zurück - dieselbe Bewegung wie ein Tipp auf den Pfeil.
Seit dem 02.09.2026 folgt die Seite dabei dem Finger (randZeigen): ohne
diese Rückmeldung passierte bis zum Loslassen nichts sichtbares, und wer
die Geste zu kurz zog, sah nicht, dass er sie überhaupt begonnen hatte.
Unter der Auslöseschwelle federt sie zurück - die Geste ist damit
widerrufbar, indem man den Finger zurückführt. Gleichlautend in der App
(useWischZurueck in Shell.tsx). */
let randX0 = null, randY0 = null;
function randZeigen(dx) {
const ansicht = $("#view");
if (!ansicht) return;
if (dx === null) { ansicht.style.transition = ""; ansicht.style.transform = ""; return; }
ansicht.style.transition = "none";
ansicht.style.transform = `translateX(${Math.min(140, dx)}px)`;
}
function randwischenVerdrahten() {
const phone = ROOT.querySelector(".phone");
phone.addEventListener("pointerdown", (e) => {
@@ -4905,11 +4962,19 @@ function randwischenVerdrahten() {
const links = phone.getBoundingClientRect().left;
if (e.clientX - links <= 26 && !istTab(route.name)) { randX0 = e.clientX; randY0 = e.clientY; }
});
phone.addEventListener("pointerup", (e) => {
if (randX0 !== null && e.clientX - randX0 > 62 && Math.abs(e.clientY - randY0) < 60) zurueck();
randX0 = null;
phone.addEventListener("pointermove", (e) => {
if (randX0 === null || !e.isPrimary) return;
const dx = e.clientX - randX0;
// Senkrecht gemeint: die Geste gehört dem Scrollen, nicht uns.
if (Math.abs(e.clientY - randY0) > 60) { randX0 = null; randZeigen(null); return; }
if (dx > 0) randZeigen(dx);
});
phone.addEventListener("pointercancel", () => { randX0 = null; });
phone.addEventListener("pointerup", (e) => {
const ausloesen = randX0 !== null && e.clientX - randX0 > 62 && Math.abs(e.clientY - randY0) < 60;
randX0 = null; randZeigen(null);
if (ausloesen) zurueck();
});
phone.addEventListener("pointercancel", () => { randX0 = null; randZeigen(null); });
}
/* -------------------------------------------------------------- Aktionen
@@ -6273,7 +6338,7 @@ class AudiDashboardPanel extends HTMLElement {
<div class="sync" id="sync"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="m18.65 4.95 2.56 2.55-2.56 2.55-.7-.7L19.29 8H7.2A5.24 5.24 0 0 0 2 13.38V14H1v-.6A6.24 6.24 0 0 1 7.2 7h12.1l-1.34-1.35.7-.7ZM23 11.59A6.24 6.24 0 0 1 16.8 18H4.7l1.35 1.35-.7.7-2.56-2.55 2.56-2.55.7.7L4.71 17H16.8a5.24 5.24 0 0 0 5.2-5.38V11h1v.6Z"/></svg><span></span></div></div>
<span class="kopfgegengewicht" aria-hidden="true"></span>
<button class="themebtn" id="theme" hidden aria-hidden="true"><svg id="themeicon" viewBox="0 0 20 20" width="15" height="15"></svg></button>
<button class="profilbtn" id="ringsbtn" aria-label="Einstellungen"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.6 1.6 0 0 0 .33 1.76l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.6 1.6 0 0 0-1.76-.33 1.6 1.6 0 0 0-1 1.47V21a2 2 0 1 1-4 0v-.1a1.6 1.6 0 0 0-1-1.47 1.6 1.6 0 0 0-1.77.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.6 1.6 0 0 0 4.6 15a1.6 1.6 0 0 0-1.47-1H3a2 2 0 1 1 0-4h.1a1.6 1.6 0 0 0 1.47-1 1.6 1.6 0 0 0-.33-1.77l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.6 1.6 0 0 0 9 4.6 1.6 1.6 0 0 0 10 3.13V3a2 2 0 1 1 4 0v.1a1.6 1.6 0 0 0 1 1.47 1.6 1.6 0 0 0 1.77-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.6 1.6 0 0 0 19.4 9v.04a1.6 1.6 0 0 0 1.47 1H21a2 2 0 1 1 0 4h-.1a1.6 1.6 0 0 0-1.5 1Z"/></svg></button>
<button class="profilbtn" id="ringsbtn" aria-label="Einstellungen">${ciSVG(CI.settingsS, 19)}</button>
</div>
<div class="ptr" id="ptr"><svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M20.06 8.36c-1.21-3.33-4.3-6.46-8.5-6.46-3.69 0-6.97 2-8.55 5.28a9.47 9.47 0 0 0 4.23 12.77A9.56 9.56 0 0 0 20 15.72l-.89-.44a8.56 8.56 0 0 1-11.43 3.77A8.47 8.47 0 0 1 3.9 7.62a8.38 8.38 0 0 1 7.65-4.72c3.8 0 6.64 2.94 7.66 6.1h-5.66v1h7.5V2.5h-1v5.86Z"/></svg><span>Ziehen zum Aktualisieren</span></div>
<main id="view"><div class="tile" style="margin-top:20px;display:flex;align-items:center;gap:10px"><span class="lade-spinner" aria-hidden="true"></span><span class="label" style="margin:0">Lädt </span></div></main>
@@ -120,6 +120,9 @@ html,body{height:100%}
.phone:has(.back.on) .kopfgegengewicht{display:block;order:10;flex:none;width:34px;height:34px;margin-right:-8px}
.phone:has(.back.on) .topbar{padding:54px 16px 6px;gap:8px}
.phone:has(.back.on) .head{align-items:center}
/* Zentriert nach Apples HIG (kleiner Titel in der Navigationsleiste einer
Detailseite; der große, linksbündige gilt nur auf oberster Ebene). Siehe
.dm-kopf--unterseite in der App - dort steht die ganze Begründung. */
.phone:has(.back.on) .title{font-size:17px;font-weight:400;letter-spacing:-.01em;text-align:center}
.phone:has(.back.on) .sync{margin-top:2px;font-size:12.5px}
@@ -212,7 +212,11 @@
border: none; background: var(--tile); color: var(--fg2);
cursor: pointer; padding: 0; flex: 0 0 auto;
}
.profilbtn svg { width: 19px; height: 19px; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
/* Flaeche statt Kontur, seit das Zahnrad settings-s aus dem CI-Satz ist
(02.09.2026): der Pfad bringt sein eigenes fill="currentColor" mit, und ein
geerbter stroke wuerde zusaetzlich um jede Flaeche gezeichnet - dieselbe
Falle wie bei .voll/RahmenFlaeche in der App. */
.profilbtn svg { width: 19px; height: 19px; fill: currentColor; stroke: none; }
.profilbtn:hover { color: var(--fg); }
.profilbtn:active { background: var(--tile-2); color: var(--fg); transform: scale(.94); }
@@ -331,7 +335,7 @@ button.tile, .tilebtn { transition: background .15s, transform .1s; }
}
.zahnrad {
position: absolute;
top: 11px; right: 11px;
top: 8px; right: 8px;
width: 44px; height: 44px;
display: inline-flex; align-items: center; justify-content: center;
border: none; border-radius: 50%;
@@ -341,9 +345,14 @@ button.tile, .tilebtn { transition: background .15s, transform .1s; }
.zahnrad:hover { background: var(--tile-2); color: var(--fg); }
.zahnrad:active { background: var(--tile-2); color: var(--fg); transform: scale(.92); }
.zahnrad svg { width: 17px; height: 17px; }
/* Titelzeile nie unter dem Zahnrad/Chevron hindurchlaufen lassen */
/* Titelzeile nie unter dem Zahnrad/Chevron hindurchlaufen lassen - und die
Zeile hoch genug für die 44px-Trefferfläche des Zahnrads machen. Ohne die
Mindesthöhe ragte es in den Kachelinhalt und lag auf dem ersten Knopf
(Versicherung, "Inland · <Nummer>"; gemeldet 02.09.2026). Gleiche Regel in
der App (.dm-kachel--zahnrad). */
.tile:has(> .zahnrad) > .label:first-child,
.tile:has(> .go) > .label:first-child { padding-right: 44px; }
.tile:has(> .go) > .label:first-child { padding-right: 48px; }
.tile:has(> .zahnrad) > .label:first-child { min-height: 34px; }
/* C1: Abschnittstitel gemischt geschrieben, 14 px, ohne Sperrung - wie in der
aktuellen myAudi-App. Versalien trägt nur noch .eyebrow über dem Seitentitel. */
@@ -651,6 +660,17 @@ img.carfix.klein { aspect-ratio: auto; height: auto; }
hoeher zu ruecken - auf Ansage des Besitzers steht er wieder in voller
Groesse, weil ein Streifen nicht erkennen laesst, was dort hingehoert. */
.bildbox.bild-fehlt.carfix.klein { aspect-ratio: 16 / 8; height: auto; }
/* Die Draufsicht auf der Fahrzeugseite ist eine Beigabe, keine Szene: sie
steht ueber den Sicherheitszeilen und soll die Zeilen nicht nach unten
draengen. Statt randlos ueber die volle Breite (rund 374px auf einem
Telefon, mit 16:8 also 187px hoch) steht sie eingerueckt und mittig -
60% Breite, die Hoehe geht im selben Verhaeltnis mit auf rund 112px
zurueck. Auf Ansage des Eigentuemers am 02.09.2026 ("Draufsicht-Foto
Bereich ist zu gross"): schmaler, nicht flacher - flacher haette bei
einem Foto, das nicht sehr breit ist, oben und unten geschnitten. */
.szene.bleed.schmal { margin: 0; padding: 22px 0 26px; }
.szene.bleed.schmal .carbox { width: 60%; margin: 0 auto; padding: 0; }
.szene.bleed:has(.bild-fehlt)::before { display: none; }
.szene.bleed:has(.bild-fehlt) { margin-left: 0; margin-right: 0; }
.szene.bleed:has(.bild-fehlt) .carbox { padding: 0; }
@@ -124,10 +124,20 @@ class Geokodierung:
self._sperre = asyncio.Lock()
self._zuletzt = 0.0
self.rest = PRO_DURCHLAUF
self.neu = 0
def durchlauf_beginnen(self) -> None:
"""Setzt das Anfragenbudget fuer einen Screening-Lauf zurueck."""
"""Setzt Anfragenbudget und Fortschrittszaehler fuer einen Lauf zurueck.
`neu` zaehlt die in diesem Lauf DAZUGEKOMMENEN Ergebnisse - nur
gespeicherte, also auch das ehrliche "dort ist nichts", aber kein
Netzfehler. Das Screening entscheidet daran, ob ein zweiter Lauf noch
etwas bringen kann (screening._nachfassen_planen): Budget aufgebraucht
UND Fortschritt heisst "es liegt noch Arbeit an"; Budget aufgebraucht
OHNE Fortschritt heisst "die Gegenstelle antwortet gerade nicht" - und
dann waere Nachfassen im Minutentakt genau das Falsche."""
self.rest = PRO_DURCHLAUF
self.neu = 0
# ------------------------------------------------------ Zwischenspeicher
@@ -200,6 +210,7 @@ class Geokodierung:
return None
speicher[key] = ergebnis
self.neu += 1
await self._hass.async_add_executor_job(self._sichern, dict(speicher))
return ergebnis
@@ -131,6 +131,9 @@ class Koordinator:
self.tiefststand_liter: float | None = None
# Seit wann das Fahrzeug steht (Zeit des Geraets). None = unbekannt.
self.geparkt_seit_ts: datetime.datetime | None = None
# Ein eingeplanter zweiter Screening-Lauf, siehe screening.py
# (_nachfassen_planen). None = keiner offen.
self.screening_nachlauf: CALLBACK_TYPE | None = None
# Ergebnis des letzten "Auf Update prüfen"/"Update installieren" -
# nur auf Tastendruck gesetzt (siehe dienste.py), nie im normalen
# Veröffentlichungs-Takt neu berechnet, damit keine automatischen
@@ -186,8 +189,16 @@ class Koordinator:
# vollständig - siehe die Begründung von trigger_neu_binden() oben.
await identitaet.fin_ableiten_und_uebernehmen(self.hass, self.zuordnung, self.ablage)
await self.profil_veroeffentlichen()
# Genau hier kommt eine frisch aktualisierte Integration an, und was
# ihre neuen Felder nachtragen koennen, soll nicht bis zur naechsten
# Fahrt warten. Reicht das Ortsbudget eines Laufs nicht, plant das
# Screening selbst einen zweiten ein (screening._nachfassen_planen).
await screening.durchfuehren(self)
async def beenden(self) -> None:
if self.screening_nachlauf is not None:
self.screening_nachlauf()
self.screening_nachlauf = None
for abmelden in (*self._takte, *self._beobachter):
abmelden()
self._takte.clear()
@@ -1,7 +1,7 @@
{
"domain": "audi_dashboard",
"name": "Audi Dashboard",
"version": "2026.9.2.7",
"version": "2026.9.2.12",
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
"codeowners": ["@paul"],
+46 -1
View File
@@ -18,7 +18,9 @@ verlauf.py ausschließt.
Läuft nach jedem Fahrtende und zusätzlich bei jeder Änderung des
Kilometerstand-Sensors - unabhängig vom Fahrtende-Ereignis selbst, eben weil
der Wert auch erst mit der nächsten Fahrt eintreffen kann.
der Wert auch erst mit der nächsten Fahrt eintreffen kann. Dazu einmal nach
dem Start von Home Assistant, und danach so lange erneut, wie ein Lauf mehr
Ortsauflösungen bräuchte, als sein Budget hergibt (_nachfassen_planen).
VORBEHALT: Der GPS-Fallback (km_quelle="gps") ist weiterhin nicht umgesetzt,
da die Adressauflösung ein eigenes Arbeitspaket ist. Fahrten ohne passenden
@@ -31,6 +33,8 @@ import datetime
import logging
from typing import TYPE_CHECKING
from homeassistant.helpers.event import async_call_later
from . import reifen, verbrauchskorrektur
from .verlauf import (
UNPLAUSIBLE_KMH,
@@ -51,6 +55,10 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
# Wie lange nach einem Lauf gewartet wird, dessen Ortsbudget nicht gereicht
# hat. Siehe _nachfassen_planen().
NACHFASSEN_S = 60
# Wie weit vor/zurück gesucht wird, ausgehend von Fahrtbeginn bzw. -ende.
# 48h deckt auch länger stehende Fahrzeuge ab, ohne bei jedem Screening die
# komplette Historie zu laden.
@@ -214,6 +222,43 @@ async def durchfuehren(k: Koordinator) -> None:
await _lueckenlos_verketten(k)
await k.fahrten_veroeffentlichen()
_nachfassen_planen(k)
def _nachfassen_planen(k: Koordinator) -> None:
"""Plant einen zweiten Lauf, wenn dieser hier nicht gereicht hat.
Das Ortsbudget sind acht Anfragen je Lauf (PRO_DURCHLAUF in
geokodierung.py, damit Nominatims Bitte um keinen Vorratsabruf gewahrt
bleibt). Eine einzelne Fahrt braucht zwei davon - im Normalbetrieb ist das
Budget also nie das Ende. Aufgebraucht wird es genau in zwei Lagen: nach
einem Rueckblick-Import aus Home Assistant, der viele Fahrten auf einmal
anlegt, und einmalig nach einem Update, das ein neues Feld einfuehrt.
Genau dafuer und nur dafuer ist dieses Nachfassen da - kein Dauertakt.
Bedingung ist BEIDES: das Budget ist leer (es liegt also noch Arbeit an)
UND es ist in diesem Lauf mindestens ein Ort dazugekommen (die Gegenstelle
antwortet also). Kommt ein Lauf mit uebrigem Budget durch, ist alles
aufgeloest und es wird nichts mehr eingeplant - die Kette endet von
selbst. Antwortet Nominatim gerade nicht, endet sie ebenfalls, statt im
Minutentakt gegen eine Wand zu laufen; der naechste natuerliche Ausloeser
(Fahrtende, Kilometerstand, Neustart) versucht es wieder.
"""
if k.screening_nachlauf is not None:
return
if k.geokodierung.rest > 0 or k.geokodierung.neu <= 0:
return
async def _spaeter(_jetzt) -> None:
k.screening_nachlauf = None
await durchfuehren(k)
_LOGGER.debug(
"Ortsbudget aufgebraucht, %s Orte neu - weiterer Lauf in %s s",
k.geokodierung.neu,
NACHFASSEN_S,
)
k.screening_nachlauf = async_call_later(k.hass, NACHFASSEN_S, _spaeter)
async def _fahrt_screenen(k: Koordinator, km_sensor: str, fahrt: dict) -> None: