diff --git a/AGENTS.md b/AGENTS.md index 073c03c..bbff0fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md — Project state, review findings, open items, and working rules **Last updated: 2026-08-30** (owner findings batch - vehicle pin, tile chevrons, select arrows, -SmartDeal dialog, address cache and pull-to-refresh; manifest `2026.8.30.9`, see section AO. +SmartDeal dialog, address cache and pull-to-refresh; manifest `2026.8.30.10`, see section AO. Before that: design-audit follow-through - the five priority items applied to both codebases, plus the location preview's wrong pin; manifest `2026.8.30.8`, see section AN. Before that: third parity round — the panel had never actually rendered Audi @@ -5879,6 +5879,57 @@ vs 1.1 right, measured via `getBBox`) and is now nudged onto centre and scaled t outweighing its outline neighbours; Teilen uses the real `share-s` from the CI set; and the sheet's close button only exists while the sheet is raised - half-covered it looked operable and did nothing. +**The "could not be reproduced" finding was a night-mode-only defect (2026.8.30.10).** The owner +added the missing detail: night mode. In night mode `--tile` is deliberately translucent - a white +veil over the dark page - and `.ads-swipe-content` painted the same token a second time on top of +the tile it already sits on. Two veils add up, so every row in Fahrten and Tanken was visibly lighter +than the tile around it, while the year and month headers (not swipe rows) stayed correct. In day +mode both values are opaque white, which is why painting white on white showed nothing and the first +pass found the two sides identical. The panel does not have this: its iOS overlay sets +`.swipe-wrap .swipe-content{background:var(--tile-deckend)}`, an opaque token that `design-system` +never had. Added `--tile-deckend` (#171B21 / #FFFFFF, the panel's own values) and switched +`SwipeRow.css` to it. **Rule worth keeping: any surface painted on top of another surface needs the +opaque token, never `--tile`** - the bug is invisible in day mode by construction. + +**Pull-to-refresh never worked on a real device, in either app - and the reason invalidates how it +was verified before.** Both implementations drove the gesture from `pointermove` and called +`preventDefault()` there. Per the Pointer Events spec that does **not** prevent scrolling: scrolling +is decided by `touch-action` alone. On a real touch screen the browser claims the vertical gesture, +fires `pointercancel` and stops sending `pointermove`; the handler's own cancel path then resets the +drag long before the 56px threshold. A mouse never triggers that takeover, and neither do synthetic +`PointerEvent`s - which is precisely why the 2026-08-17 session drove the whole gesture end to end +with dispatched pointer events, saw it work, and concluded the JS was fine. It was fine; it was +attached to the wrong event family. The `overscroll-behavior-y: contain` added then addresses scroll +*chaining* to the host page, a different problem, and was left in place. + +Both now run the gesture on touch events - `touchstart` passive, `touchmove` with +**`{ passive: false }`** (without that flag `preventDefault()` is ignored and the browser scrolls +anyway), `touchend`/`touchcancel` - while the pointer path stays for mouse and stylus and now +ignores `pointerType === "touch"` so the two cannot fight over the same gesture. Panel: `ptrStarten()` +/ `ptrBewegen()` / `ptrBeenden()` extracted so both paths share one implementation. App: a manual +listener in a `useEffect`, because React registers `touchmove` passively and `preventDefault()` in an +`onTouchMove` prop would be silently ignored. Verified in both with genuine `TouchEvent`s - which the +old code could not have satisfied: threshold reached, label switching to "Loslassen zum +Aktualisieren", the refresh running, and `defaultPrevented === true` on the move, the one signal that +proves the native overscroll is actually suppressed. + +One trap avoided while doing it: the app's early `return` for the wide layout sat in front of the new +`useEffect`, which would have made the hook run on some renders and not others. Moved behind it. + +**Named places instead of a street address.** On the owner's question - a car park says more than the +nearest house number. Nominatim already returns `name` plus the type of the matched feature; both +apps now prefer that name when the type is one of parking / parking_space / parking_entrance / +bicycle_parking / motorcycle_parking / rest_area / services / fuel / charging_station, with the town +still appended. The type check is what keeps any incidentally named building from displacing the +address. And to answer the other half plainly: when nothing resolves at all, both show the +coordinates - that was already the designed fallback (`adresse ?? koordinaten`), and it is also what +a throttled lookup produces, which is exactly why the two cases were indistinguishable before the +cache landed. + +**Header padding on the app was cut to the safe area on the owner's instruction** - see the note at +the end of section AN; the panel's 56px sit below Home Assistant's own header, the native shell has +nothing above them but the status bar. + **One finding could not be reproduced:** "Fahrten/Tankfüllung: Auflistung/grauer Hintergrund ist falsch". Panel and app are identical there - same backgrounds (transparent rows on a white tile), same paddings, same indents, same text. Reported back rather than guessed at. diff --git a/companion-app/src/Shell.tsx b/companion-app/src/Shell.tsx index ff8b814..cb834a9 100644 --- a/companion-app/src/Shell.tsx +++ b/companion-app/src/Shell.tsx @@ -53,12 +53,8 @@ function useZiehenAktualisieren( const [laedt, setzeLaedt] = useState(false) const start = useRef(null) - if (!beiAktualisieren) { - return { zug: 0, laedt: false, handler: {} as Record } - } - const beenden = () => { - if (start.current === null) return + if (start.current === null || !beiAktualisieren) return start.current = null if (zug >= PTR_SCHWELLE) { setzeLaedt(true) @@ -71,26 +67,77 @@ function useZiehenAktualisieren( } } + const starten = (y: number) => { + if (laedt) return + if ((inhalt.current?.scrollTop ?? 0) > 0) return + start.current = y + setzeZug(0) + } + /* Gibt true zurück, wenn die Geste uns gehört — der Aufrufer muss dann + preventDefault() rufen. */ + const bewegen = (y: number): boolean => { + if (start.current === null) return false + const dy = y - start.current + if (dy > 0 && (inhalt.current?.scrollTop ?? 0) <= 0) { + // Widerstand wie bei iOS: halbe Strecke, gedeckelt bei 90px. + setzeZug(Math.min(90, dy * 0.5)) + return true + } + start.current = null + setzeZug(0) + return false + } + + /* Auf dem Finger läuft die Geste über Touch-Ereignisse, nicht über Pointer. + Grund: preventDefault() auf einem Pointer-Ereignis verhindert laut + Spezifikation kein Scrollen — darüber entscheidet allein touch-action. Auf + einem echten Gerät übernimmt der Browser die senkrechte Geste deshalb + sofort, schickt pointercancel und stellt pointermove ein; der Zug wurde + zurückgesetzt, bevor er die Schwelle erreichte. Am Rechner fällt das nie + auf, eine Maus kennt keine Scroll-Übernahme. Der Listener muss von Hand + und mit passive:false hängen — React registriert touchmove passiv, dort + wäre preventDefault() wirkungslos. */ + useEffect(() => { + const el = inhalt.current + // Der Aussteiger sitzt IM Effekt, nicht davor: ein vorgezogenes return + // wuerde diesen Hook je nach Bildschirmbreite mal aufrufen und mal nicht + // und damit die Hook-Reihenfolge brechen. + if (!el || !beiAktualisieren) return undefined + const beiStart = (e: TouchEvent) => { + if (e.touches.length === 1) starten(e.touches[0]!.clientY) + } + const beiBewegung = (e: TouchEvent) => { + if (e.touches.length !== 1) return + if (bewegen(e.touches[0]!.clientY)) e.preventDefault() + } + el.addEventListener("touchstart", beiStart, { passive: true }) + el.addEventListener("touchmove", beiBewegung, { passive: false }) + el.addEventListener("touchend", beenden) + el.addEventListener("touchcancel", beenden) + return () => { + el.removeEventListener("touchstart", beiStart) + el.removeEventListener("touchmove", beiBewegung) + el.removeEventListener("touchend", beenden) + el.removeEventListener("touchcancel", beenden) + } + }) + + if (!beiAktualisieren) { + return { zug: 0, laedt: false, handler: {} as Record } + } + return { zug, laedt, handler: { + // Nur Maus/Stift — der Finger läuft über die Touch-Ereignisse oben. onPointerDown: (e: ReactPointerEvent) => { - if (!e.isPrimary || laedt) return - if ((inhalt.current?.scrollTop ?? 0) > 0) return - start.current = e.clientY - setzeZug(0) + if (!e.isPrimary || e.pointerType === "touch") return + starten(e.clientY) }, onPointerMove: (e: ReactPointerEvent) => { - if (start.current === null || !e.isPrimary) return - const dy = e.clientY - start.current - if (dy > 0 && (inhalt.current?.scrollTop ?? 0) <= 0) { - // Widerstand wie bei iOS: halbe Strecke, gedeckelt bei 90px. - setzeZug(Math.min(90, dy * 0.5)) - } else { - start.current = null - setzeZug(0) - } + if (!e.isPrimary || e.pointerType === "touch") return + bewegen(e.clientY) }, onPointerUp: beenden, onPointerCancel: beenden, diff --git a/companion-app/src/daten/geokodierung.ts b/companion-app/src/daten/geokodierung.ts index d36807e..36f0c79 100644 --- a/companion-app/src/daten/geokodierung.ts +++ b/companion-app/src/daten/geokodierung.ts @@ -23,6 +23,20 @@ export interface Koordinate { const CACHE_SCHLUESSEL = "dm360.geocache" +/* Arten, bei denen der Eigenname aussagekräftiger ist als die Anschrift + (Feld type der Nominatim-Antwort). */ +const PLATZ_ARTEN = new Set([ + "parking", + "parking_space", + "parking_entrance", + "bicycle_parking", + "motorcycle_parking", + "rest_area", + "services", + "fuel", + "charging_station", +]) + function cacheLesen(): Record { try { const roh = localStorage.getItem(CACHE_SCHLUESSEL) @@ -75,13 +89,25 @@ export async function koordinatenAufloesen(lat: number, lon: number): Promise display_name?: string } const a = daten.address ?? {} const strasse = [a["road"], a["house_number"]].filter(Boolean).join(" ") const ort = [a["postcode"], a["city"] ?? a["town"] ?? a["village"]].filter(Boolean).join(" ") - const adresse = [strasse, ort].filter(Boolean).join(", ") || daten.display_name || null + /* Steht das Auto auf einem benannten Platz — Parkplatz, Parkhaus, + Rastplatz, Ladesäule —, sagt dessen Name mehr als die nächstgelegene + Hausnummer: "Parkhaus Westentor" statt "Rathausstraße 4". Nominatim + liefert ihn im Feld name, zusammen mit der Art der getroffenen Fläche; + ohne die Prüfung auf diese Art würde auch jedes beliebige Gebäude, das + zufällig einen Namen trägt, die Anschrift verdrängen. Der Ort bleibt + angehängt, damit erkennbar ist, wo der Platz liegt. */ + const platz = PLATZ_ARTEN.has(daten.type ?? "") ? (daten.name ?? "").trim() : "" + const adresse = platz + ? [platz, ort].filter(Boolean).join(", ") + : [strasse, ort].filter(Boolean).join(", ") || daten.display_name || null // Nur echte Treffer merken: eine gedrosselte Antwort ist kein "hier gibt // es keine Adresse", die soll beim nächsten Mal erneut versucht werden. if (adresse) adresseMerken(schluessel, adresse) diff --git a/custom_components/audi_dashboard/frontend/app/bundle.json b/custom_components/audi_dashboard/frontend/app/bundle.json index 28a6083..a6b91a3 100644 --- a/custom_components/audi_dashboard/frontend/app/bundle.json +++ b/custom_components/audi_dashboard/frontend/app/bundle.json @@ -1 +1 @@ -{"version":"2026.8.30.9","sha256":"63b67b2116bdc2970ebcb9c02dc8c2d1ec1f0156fd70c59f88db101307054474","bytes":256695,"gebaut":"2026-08-30T21:09:06Z"} \ No newline at end of file +{"version":"2026.8.30.10","sha256":"0a9cdbbccf4e91b07152699e0331bf75ff62908832880e6c2408981b76347710","bytes":257008,"gebaut":"2026-08-30T21:20:20Z"} \ No newline at end of file diff --git a/custom_components/audi_dashboard/frontend/app/bundle.zip b/custom_components/audi_dashboard/frontend/app/bundle.zip index 9f9b0ba..9b0cd0e 100644 Binary files a/custom_components/audi_dashboard/frontend/app/bundle.zip and b/custom_components/audi_dashboard/frontend/app/bundle.zip differ diff --git a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js index 97d226b..daa65da 100644 --- a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js +++ b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js @@ -786,6 +786,10 @@ function standortErfassenFallsNoetig() { dokumentierte Leaflet-über-CDN-Vorgehen (siehe AGENTS.md). Cache-Schlüssel auf 3 Nachkommastellen (~110 m) gerundet, damit ein erneutes Öffnen an derselben Stelle keinen zweiten Abruf auslöst. */ +/* Arten, bei denen der Eigenname aussagekraeftiger ist als die Anschrift. */ +const PLATZ_ARTEN = new Set(["parking", "parking_space", "parking_entrance", + "bicycle_parking", "motorcycle_parking", "rest_area", "services", "fuel", + "charging_station"]); async function standortAdresseAufloesen(lat, lon) { const key = lat.toFixed(3) + "," + lon.toFixed(3); if (STANDORT_ADRESSE_KEY === key) return STANDORT_ADRESSE; @@ -808,7 +812,16 @@ async function standortAdresseAufloesen(lat, lon) { const daten = await res.json(); const a = daten.address || {}; const strasse = [a.road, a.house_number].filter(Boolean).join(" "); - STANDORT_ADRESSE = [strasse, [a.postcode, a.city || a.town || a.village].filter(Boolean).join(" ")].filter(Boolean).join(", ") || daten.display_name || ""; + const ort = [a.postcode, a.city || a.town || a.village].filter(Boolean).join(" "); + /* Steht das Auto auf einem benannten Platz - Parkplatz, Parkhaus, + Rastplatz -, sagt dessen Name mehr als die naechstgelegene Hausnummer. + Nominatim liefert ihn im Feld name; die Pruefung auf die Art der + Flaeche verhindert, dass jedes zufaellig benannte Gebaeude die + Anschrift verdraengt. */ + const platz = PLATZ_ARTEN.has(daten.type) ? (daten.name || "").trim() : ""; + STANDORT_ADRESSE = platz + ? [platz, ort].filter(Boolean).join(", ") + : [strasse, ort].filter(Boolean).join(", ") || daten.display_name || ""; } catch (e) { // Den Schluessel bewusst NICHT setzen: sonst gilt ein einzelner // fehlgeschlagener Abruf (Rate-Limit, kurzer Netzausfall) fuer diese Zelle @@ -4803,32 +4816,16 @@ function ereignisseVerdrahten() { ROOT.addEventListener("pointerdown", (e) => { // Ziehen zum Aktualisieren (nur Übersicht, nur wenn ganz oben gescrollt) - // unabhängig von der Wischgeste unten, da hier keine .swipe-wrap nötig ist. - if (e.isPrimary && route.name === "home" && !ptrLaedt) { - const ansicht = $("#view"); - if (ansicht && ansicht.scrollTop <= 0) { ptrY0 = e.clientY; ptrDrag = 0; } - } + // Nur Maus/Stift. Der Finger laeuft ueber die Touch-Ereignisse weiter + // unten, weil nur dort preventDefault() das native Ueberscrollen wirklich + // verhindert - siehe ptrBewegen(). + if (e.isPrimary && e.pointerType !== "touch") ptrStarten(e.clientY); const wrap = e.target.closest(".swipe-wrap"); if (!wrap || !e.isPrimary) return; swipeAktivWrap = wrap; swipeStartX = e.clientX; swipeStartY = e.clientY; swipeGeste = null; swipeDX = 0; }); ROOT.addEventListener("pointermove", (e) => { - if (ptrY0 !== null && e.isPrimary) { - const ansicht = $("#view"), dy = e.clientY - ptrY0; - if (dy > 0 && ansicht && ansicht.scrollTop <= 0) { - e.preventDefault(); - ptrDrag = Math.min(90, dy * 0.5); // Widerstand, wie bei iOS - const ptr = $("#ptr"); - if (ptr) { - ptr.style.transition = "none"; - ptr.style.height = ptrDrag + "px"; - ptr.querySelector("span").textContent = ptrDrag >= 56 ? "Loslassen zum Aktualisieren" : "Ziehen zum Aktualisieren"; - } - } else { - ptrY0 = null; ptrDrag = 0; - const ptr = $("#ptr"); - if (ptr) { ptr.style.transition = ""; ptr.style.height = "0"; } - } - } + if (e.pointerType !== "touch") ptrBewegen(e.clientY); if (!swipeAktivWrap || !e.isPrimary) return; const dx = e.clientX - swipeStartX, dy = e.clientY - swipeStartY; if (swipeGeste === null) { @@ -4863,6 +4860,39 @@ function ereignisseVerdrahten() { // auf den gerade erst aufgedeckten Löschen-Knopf) fälschlich verschluckt. if (swipeWarGezogen) setTimeout(() => { swipeWarGezogen = false; }, 0); } + function ptrStarten(y) { + if (route.name !== "home" || ptrLaedt) return; + const ansicht = $("#view"); + if (ansicht && ansicht.scrollTop <= 0) { ptrY0 = y; ptrDrag = 0; } + } + /* Gibt true zurueck, wenn die Geste uns gehoert - der Aufrufer muss dann + preventDefault() rufen. Warum das ueber Touch-Ereignisse laufen muss: + preventDefault() auf einem Pointer-Ereignis verhindert laut Spezifikation + KEIN Scrollen; darueber entscheidet allein touch-action. Auf einem echten + Touch-Geraet uebernimmt der Browser die senkrechte Geste deshalb sofort, + schickt pointercancel und stellt pointermove ein - die Geste wurde + zurueckgesetzt, bevor sie die 56px erreichen konnte. Am Rechner faellt das + nie auf, weil eine Maus keine Scroll-Uebernahme kennt; auch synthetische + PointerEvents durchlaufen sie nicht. Auf touchmove dagegen wirkt + preventDefault() und haelt das native Ueberscrollen an. */ + function ptrBewegen(y) { + if (ptrY0 === null) return false; + const ansicht = $("#view"), dy = y - ptrY0; + if (dy > 0 && ansicht && ansicht.scrollTop <= 0) { + ptrDrag = Math.min(90, dy * 0.5); // Widerstand, wie bei iOS + const ptr = $("#ptr"); + if (ptr) { + ptr.style.transition = "none"; + ptr.style.height = ptrDrag + "px"; + ptr.querySelector("span").textContent = ptrDrag >= 56 ? "Loslassen zum Aktualisieren" : "Ziehen zum Aktualisieren"; + } + return true; + } + ptrY0 = null; ptrDrag = 0; + const ptr = $("#ptr"); + if (ptr) { ptr.style.transition = ""; ptr.style.height = "0"; } + return false; + } function ptrBeenden() { if (ptrY0 === null) return; ptrY0 = null; @@ -4886,6 +4916,19 @@ function ereignisseVerdrahten() { ROOT.addEventListener("pointerup", (e) => { swipeBeenden(); ptrBeenden(); }); ROOT.addEventListener("pointercancel", (e) => { swipeBeenden(); ptrBeenden(); }); + // Ziehen zum Aktualisieren auf dem Finger: eigener Weg ueber Touch-Ereignisse + // (Begruendung in ptrBewegen()). passive:false ist zwingend - ohne das ist + // preventDefault() auf touchmove wirkungslos und der Browser scrollt doch. + ROOT.addEventListener("touchstart", (e) => { + if (e.touches.length === 1) ptrStarten(e.touches[0].clientY); + }, { passive: true }); + ROOT.addEventListener("touchmove", (e) => { + if (e.touches.length !== 1) return; + if (ptrBewegen(e.touches[0].clientY)) e.preventDefault(); + }, { passive: false }); + ROOT.addEventListener("touchend", () => ptrBeenden()); + ROOT.addEventListener("touchcancel", () => ptrBeenden()); + // Batteriespannungs-Verlauf: Pinch-Zoom (2 Finger) / Ziehen zum Verschieben // (1 Finger) / Mausrad-Zoom, per pointerId in bvZeiger nachverfolgt statt // über die swipe*-Variablen oben, die für horizontales Wischen in Listen diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json index 54dda8a..7a16bde 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.8.30.9", + "version": "2026.8.30.10", "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/design-system/src/components/SwipeRow/SwipeRow.css b/design-system/src/components/SwipeRow/SwipeRow.css index c4015cc..fa0e910 100644 --- a/design-system/src/components/SwipeRow/SwipeRow.css +++ b/design-system/src/components/SwipeRow/SwipeRow.css @@ -37,7 +37,11 @@ .ads-swipe-content { position: relative; - background: var(--tile); + /* Deckend, nicht --tile: die Zeile liegt auf einer Kachel, die im + Nachtmodus selbst nur ein durchscheinender Schleier ist - zweimal + gemalt wird der Streifen sichtbar heller als die Kachel um ihn herum + (.swipe-wrap .swipe-content in audi-dashboard-ios.css). */ + background: var(--tile-deckend); touch-action: pan-y; transition: transform .18s; } diff --git a/design-system/src/tokens/tokens.css b/design-system/src/tokens/tokens.css index ba50968..09c343c 100644 --- a/design-system/src/tokens/tokens.css +++ b/design-system/src/tokens/tokens.css @@ -43,6 +43,13 @@ /* Graue Füllfläche für Bedienelemente (Seg-Spur, Eingabe-/Auswahl-Boxen) - live am Panel gemessen (--ios-fill, audi-dashboard-ios.css). */ --ios-fill: rgba(120, 120, 128, .24); + /* Deckende Kachelflaeche. Im Nachtmodus ist --tile bewusst durchscheinend + (weisser Schleier ueber der dunklen Seite) - wird sie ein zweites Mal + auf eine Kachel gemalt, addieren sich die Schleier und der Bereich wird + sichtbar heller. Genau das passierte den Wischzeilen in Fahrten und + Tanken. Ueberall dort, wo eine Flaeche auf einer anderen liegt, gehoert + dieser deckende Wert hin (--tile-deckend im Panel). */ + --tile-deckend: #171B21; --font-stack: Helvetica, Arial, sans-serif; } @@ -69,6 +76,7 @@ --bad: #FF3B30; --shade: rgba(0, 0, 0, .04); --ios-fill: rgba(120, 120, 128, .16); + --tile-deckend: #FFFFFF; } /*