diff --git a/AGENTS.md b/AGENTS.md index f0a0c79..c9ca051 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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:58–12: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:` 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** `
`. 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 · " 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. diff --git a/companion-app/src/Shell.tsx b/companion-app/src/Shell.tsx index 8db7090..c1d0e43 100644 --- a/companion-app/src/Shell.tsx +++ b/companion-app/src/Shell.tsx @@ -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, + zurueck: (() => void) | undefined, + belegt: MutableRefObject, +) { + const [zug, setzeZug] = useState(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, beiAktualisieren: (() => Promise) | undefined, + belegt: MutableRefObject, ) { 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(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")} > - + Einstellungen @@ -314,7 +421,21 @@ export function Shell({ : "Ziehen zum Aktualisieren"} -
+
{children}
{ - 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", () => { diff --git a/companion-app/src/daten/statistik.ts b/companion-app/src/daten/statistik.ts index 09bd424..1d545f3 100644 --- a/companion-app/src/daten/statistik.ts +++ b/companion-app/src/daten/statistik.ts @@ -31,16 +31,32 @@ export const ZEITRAUM_NAME: Record = { 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, 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[] { diff --git a/companion-app/src/screens/Einstellungen.tsx b/companion-app/src/screens/Einstellungen.tsx index 01b1ec5..7653263 100644 --- a/companion-app/src/screens/Einstellungen.tsx +++ b/companion-app/src/screens/Einstellungen.tsx @@ -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. */} -
+
void return ( <> - {/* Draufsicht, wie im Panel (.szene.bleed, "carfix klein" = 16:8) - war - hier komplett unportiert (Parität-Audit 2026-08-30). */} -
+ {/* 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. */} +
diff --git a/companion-app/src/screens/Reifen.tsx b/companion-app/src/screens/Reifen.tsx index c3a994b..b126401 100644 --- a/companion-app/src/screens/Reifen.tsx +++ b/companion-app/src/screens/Reifen.tsx @@ -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 ( + void loeschen()}> )} + ) } diff --git a/companion-app/src/screens/Statistik.tsx b/companion-app/src/screens/Statistik.tsx index 0d0e19c..8c6b925 100644 --- a/companion-app/src/screens/Statistik.tsx +++ b/companion-app/src/screens/Statistik.tsx @@ -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="" > - - - - - - } - /> - {a.langzeit.unplausibel && ( - - Aus Litern und Strecke ergibt sich derzeit kein plausibler Wert. Sobald Tankvorgänge und - Kilometerstände vollständig vorliegen, erscheint er hier. - - )} + {/* 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. */} deOderStrich(a.jeZeitraum[z].verbrauch, 1))} /> - - 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. - void + beschriftung?: string + /** Heißt `children`, weil JSX-Verschachtelung genau diesen Namen erwartet. */ + children: ReactNode +}) { + const [offen, setzeOffen] = useState(false) + const [zug, setzeZug] = useState(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) => { + 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) => { + 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 ( +
+ +
{ + if (!offen) return + e.preventDefault() + e.stopPropagation() + setzeOffen(false) + }} + > + {children} +
+
+ ) +} + /** 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. */ diff --git a/companion-app/src/screens/bilder.ts b/companion-app/src/screens/bilder.ts index 5182db4..b4d5868 100644 --- a/companion-app/src/screens/bilder.ts +++ b/companion-app/src/screens/bilder.ts @@ -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[] = [ diff --git a/companion-app/src/stile/rahmen.css b/companion-app/src/stile/rahmen.css index 574470b..90ff72d 100644 --- a/companion-app/src/stile/rahmen.css +++ b/companion-app/src/stile/rahmen.css @@ -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; } diff --git a/companion-app/src/stile/screens.css b/companion-app/src/stile/screens.css index e4c8409..735478e 100644 --- a/companion-app/src/stile/screens.css +++ b/companion-app/src/stile/screens.css @@ -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 · + ", 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; diff --git a/companion-app/src/symbole.tsx b/companion-app/src/symbole.tsx index 7f40745..fc7bb16 100644 --- a/companion-app/src/symbole.tsx +++ b/companion-app/src/symbole.tsx @@ -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 ( - - - - - - + + } /> @@ -175,23 +184,24 @@ export function SymbolZurueck({ groesse }: SymbolProps) { return } /> } -export function SymbolZahnrad({ groesse }: SymbolProps) { - return ( - - - - - } - /> - ) -} +/* 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 ( ', + /* 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: '', mapLayer: '', carSearch: '', gps: '', @@ -1486,7 +1501,7 @@ function teaser() { ${streckeText(t.distance_km, "—")}${fahrtVerbrauch(t) != null ? de(fahrtVerbrauch(t), 1) + " l/100 km" : "—"} ` : ""} ${f ? `` : ""}
`; } @@ -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 ` -
${bildMitPlatzhalter(`/local/bilder/draufsicht.webp?v=${bildVersion}`, "draufsicht.webp", "Draufsicht", "carfix klein")}
+
${bildMitPlatzhalter(`/local/bilder/draufsicht.webp?v=${bildVersion}`, "draufsicht.webp", "Draufsicht", "carfix klein")}
${statusKreis(g)} ${esc(sammelText)} @@ -2404,7 +2419,7 @@ function vService() {
Autohaus
${esc(sv.autohaus.name)}
${esc(sv.autohaus.adresse)} @@ -2655,7 +2670,7 @@ function vVers() { return `
Versicherung - Schaden Notruf 24/7 @@ -2666,7 +2681,7 @@ function vVers() {
Jahresbeitrag ab ${dedat(new Date(v.gueltigAb))}
${eur(v.beitrag)}
${esc(v.zahlweise)} · enthält ${eur(v.steueranteil)} € Versicherungsteuer (${esc(v.steuersatz)}) @@ -2681,7 +2696,7 @@ function vVers() {
Vertrag
Gesellschaft
${esc(v.gesellschaft)}
@@ -2827,7 +2842,7 @@ function vSteuer() { return `
Kfz-Steuer
Betrag
${eur(t.betrag)} €${t.zeitraum === "jährlich" ? "im Jahr" : esc(t.zeitraum)}
@@ -2937,7 +2952,10 @@ function vEinst() { const info = bildInfo(startIndex()); return `
- ${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)}
${bildMenuOffen === info.datei ? ` @@ -3399,7 +3417,7 @@ function vReifen() { return `
Anzugsmoment der Radschrauben
${de(r.anzugsmoment)}Nm
${nmOffen ? ` @@ -3438,12 +3456,17 @@ function vReifenArchiv() { return `
Archiv ${archiv.map((a, i) => { const satzLabel = a.satz === "winter" ? "Winterräder" : "Sommerräder"; - return ` + `) + `
@@ -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) : "–", - `
-
Getankt insgesamt
${de(lv.liter, 1)} l
-
Strecke bis zum letzten Tankstop
${de(lv.km)} km${lv.basis}
-
Letzter Tankstop
${lv.bis ? dedat(lv.bis) : "–"}
-
- ${lv.unplausibel ? `Aus Litern und Strecke ergibt sich derzeit kein plausibler Wert. Sobald Tankvorgänge und Kilometerstände vollständig vorliegen, erscheint er hier.` : ""} - ${quad("Näherung je Fahrt, je Zeitraum [l/100 km]", [[schnittFahrtVerbrauch(fJahr), "Jahr"], [schnittFahrtVerbrauch(fMonat), "Monat"], [schnittFahrtVerbrauch(fWoche), "Woche"], [schnittFahrtVerbrauch(fTag), "Tag"]])} - 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.`)} + ${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", `
Tag · ${de(tagKm)} km @@ -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: `${CI.pollsS}`, - fuel: '', + /* 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: `${CI.fuelStationS}`, }; 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 {
- +
Ziehen zum Aktualisieren
Lädt …
diff --git a/custom_components/audi_dashboard/frontend/audi-dashboard-ios.css b/custom_components/audi_dashboard/frontend/audi-dashboard-ios.css index 8bf09c4..f5035d6 100644 --- a/custom_components/audi_dashboard/frontend/audi-dashboard-ios.css +++ b/custom_components/audi_dashboard/frontend/audi-dashboard-ios.css @@ -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} diff --git a/custom_components/audi_dashboard/frontend/audi-dashboard.css b/custom_components/audi_dashboard/frontend/audi-dashboard.css index 5de67ee..814cc89 100644 --- a/custom_components/audi_dashboard/frontend/audi-dashboard.css +++ b/custom_components/audi_dashboard/frontend/audi-dashboard.css @@ -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 · "; 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; } diff --git a/custom_components/audi_dashboard/geokodierung.py b/custom_components/audi_dashboard/geokodierung.py index db2365d..673425d 100644 --- a/custom_components/audi_dashboard/geokodierung.py +++ b/custom_components/audi_dashboard/geokodierung.py @@ -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 diff --git a/custom_components/audi_dashboard/koordinator.py b/custom_components/audi_dashboard/koordinator.py index f642e4d..f3c5f35 100644 --- a/custom_components/audi_dashboard/koordinator.py +++ b/custom_components/audi_dashboard/koordinator.py @@ -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() diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json index eefea02..bd3695c 100644 --- a/custom_components/audi_dashboard/manifest.json +++ b/custom_components/audi_dashboard/manifest.json @@ -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"], diff --git a/custom_components/audi_dashboard/screening.py b/custom_components/audi_dashboard/screening.py index e56fc8e..087d155 100644 --- a/custom_components/audi_dashboard/screening.py +++ b/custom_components/audi_dashboard/screening.py @@ -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: