# AGENTS.md — Project state, review findings, open items, and working rules

**Last updated: 2026-08-23** (merged the `umsetzung-datametric360` branch — companion app phases
1–10 done, see `UMSETZUNGSPLAN.md`; 2026-08-13: cleaned up remaining EU Data Act residue, fixed
oversized toggle switches, and fixed a desktop-layout audit (settings button / rings logo / popups
overflowing past the capped content column) — see `DESIGN_AUDIT_2026-08-13.md`; 2026-08-16: fixed
five user-reported bugs — removed Türschloss/Haubenschloss checks, fixed a translucent confirm-
sheet, added a flespi-style split lat/lon location source, hardened the "Lädt …" bootstrap race,
and added a generic (station-independent) fuel-receipt parser fallback; later the same day: removed
the now-unused combined `STANDORT_TRACKER` field entirely (flespi never provides it), closed a real
silent-failure gap in the receipt-upload error path, and replaced the vehicle's map marker with a
two-tone pin+car icon — see section C; a third round the same day found and fixed the long-standing
"fragmented Leaflet rendering" defect at its root — the Leaflet stylesheet was loaded into
`document.head` and therefore never reached the panel's shadow root — plus light-only map tiles,
the CI `poi-car` marker, the red frame on list rows, the select-field grey box, and two more
generic-receipt-parser gaps; a sixth round the same day fixed the map pins' real visibility problem
(the CI `poi-car`/`poi` icons are thin outline-only paths, only 13-40% filled — solved with a solid
silhouette layer extracted from each icon's own outer contour, not a redraw), applied a batch of
~20 user-reported UI polish items against Apple's Human Interface Guidelines, some via a guided
Q&A, and lowered the battery-voltage statistic's cutoff from 13.2V to 12.8V; a seventh round the
same day fixed a real CSS specificity bug that kept the wheel photo a wide 4:3 box instead of the
intended 96x96 square, moved the "Montiert" pill to match, added `color-scheme` so native `<select>`
popups stop rendering in the browser's default light palette against the dark app, root-caused and
fixed a JS crash in the "Mein Audi" image-cycle click handler, merged the separate "Fahrzeugbilder"
upload grid into "Bild der Übersicht" (pick a view, tap its photo to upload), and turned the back
arrow from red to the neutral headline color; a tenth round the same day ran a real full Apple HIG
audit (Navigation/Presentation/Selection & Input/Patterns categories, not just the sixth round's
5-page pass) and fixed the three findings that survived: closed a second, larger silent-failure gap
in `profilSpeichern()` itself (the shared save path for ~25 fields, previously an unhandled promise
rejection with zero user feedback), added a spinner to the "Lädt …" bootstrap screen, and gave the
Anzugsmoment/km-correction popups the same primary-button styling the Setup popup already used;
2026-08-17: fixed pull-to-refresh on Übersicht not working — the JS gesture logic was already
correct (verified via live pointer-event simulation), the real gap was a missing
`overscroll-behavior` on the scroll container letting the browser's own native overscroll compete
with it on real touch devices; same day: the fuel-receipt upload got a proper dialog — drag the PDF
in at the computer, paste it from the clipboard on the phone, file picker still available as a third
route — see section C; a twelfth round the same day removed `HECKKLAPPENSCHLOSS_SENSOR`, fixed a
half-cut-off image placeholder, gave the Inspektion its own "voraussichtlich am …" forecast, made
trips fully editable (new `audi_dashboard_fahrt_aktualisieren`, "Neue Fahrt" now offers every field
the detail page shows), aligned the page title with the tiles on phone width, and closed two
dead-ends in the bootstrap retry loop behind the reported hanging "Lädt …" screen; a thirteenth
round the same day replaced the trip form's editable "Dauer" with Start-/Ankunftszeit (duration is
now computed, not entered), capitalized the Art labels shown to the user, dropped the red styling
and hint text from the Arbeitsweg pill, and fixed a daylight-mode-only regression from round twelve
where the image placeholder's grey background had turned invisible-white; separately the same day,
`companion-app/` (the actual phone-app codebase, which had silently drifted six days behind the
panel) was brought back in sync with everything the panel gained since 2026-08-11 - see the
"Gap found while auditing..." entry near the end of section C; finally, the Übersicht's
Kilometerstand/Service tiles were redesigned after the owner asked how Apple HIG and Audi CI would
solve them - both answer the same way, and the merged "9,0 tkm/ 04/27" number field is gone;
2026-08-18: the owner rejected a follow-up mockup for the "Mein Audi" service preview and
"Anstehende Termine" boxes ("Lost information") - no code from that proposal was built; instead the
existing Nächster-Service box on the Übersicht had its "bis zum/zur ..." wording replaced by the
real Audi CI oil-change/inspection icons (user-supplied SVGs) placed left of the figure, in both
codebases, per the owner's explicit request); 2026-08-19: two small wording fixes on the panel's
"Mein Audi" Versicherung/Steuer summary tile (`vAudi()`) and its "Kfz-Steuer" detail tile
(`vSteuer()`) — "Zusammen" renamed to "Summe" and the "feste Kosten im Jahr" caption under the sum
dropped entirely, and the Kfz-Steuer amount's period caption now reads "im Jahr" instead of the raw
data value "jährlich" (matching the Versicherung row above it) whenever `steuer.zeitraum ===
"jährlich"`; the underlying stored value and the "jährlich"/"halbjährlich" edit-form dropdown
(`vSteuerBearbeiten()`) were deliberately left untouched, since this was a display-label fix, not a
data-model rename. No companion-app port was needed for the "Zusammen" row: `companion-app`'s "Mein
Audi" page (`MeinAudi.tsx`) only links to the Versicherung/Steuer screen via a `NaviKachel`, it never
rendered a combined Versicherung+Steuer sum in the first place, so there was nothing to change there
(pre-existing structural gap, not part of this fix). Companion's `Steuer()` edit screen has no
read-only "im Jahr"/"jährlich" display either — its only "jährlich" is a fallback default string
inside an editable-sentence, structurally different from the panel's read-only tiles — so it was left
as-is too. Later the same day: the "Vertrag" tile on that same "Mein Audi" Versicherung/Steuer page
was previously read-only end-to-end (Gesellschaft, Umfang, Vertragsnummer, Selbstbeteiligung,
Schadenfreiheitsklasse) — made every line editable, plus add/delete for the Selbstbeteiligung list
specifically (the only true repeating list among those fields). Panel: new `vVertragBearbeiten()`
view (route `vertrag`, back to `vers`) reached via a new zahnrad button on the "Vertrag" tile in
`vVers()`; Selbstbeteiligung rows get a small inline "×" delete button (new `.zeile-loeschen` CSS,
same 44×44pt invisible-`::before` tap-target trick as `.km-edit`) and a "+ Position hinzufügen"
`.aktion` button that pushes `["", ""]`; Schadenfreiheitsklasse "zuvor" (`sfAlt`) is edited here too
(the *current* SF stays editable only via the pre-existing "Beitrag anpassen" screen, cross-
referenced with a hint span, not duplicated). Companion: new `Vertrag()` screen (route `vertrag`,
its own NaviKachel on the Versicherung page) follows the codebase's own established edit-screen idiom
(local `entwurf` state + explicit "Speichern" button) rather than the panel's per-field autosave —
same behavior, different idiom, per the parity rule. Selbstbeteiligung edit/add/delete moved here
from the old read-only display inside `Vertragsdetails()`, which is removed (was showing the same
list twice would have been confusing). Schadenfreiheitsklasse is *not* added to companion's new
screen: companion never displayed `teile[].sf`/`sfAlt` anywhere before this change (not even
read-only) — building a whole new UI section for a field the app never surfaced is out of scope for
"make the existing lines editable"; documented here as a pre-existing gap, not silently dropped.
Verified live in the Docker test container (panel: add/edit/delete a Selbstbeteiligung position,
values persisted through profilSpeichern()+re-render) and via `tsc --noEmit` + the companion-app test
suite (100/100 incl. the render-every-page smoke test, which now also covers `vertrag`) + `vite
build`, since companion-app needs a live backend connection the local dev server doesn't have.
2026-08-23: **retroactive data import** — see section F below for the full entry. In short: HA's
recorder keeps sensor history only 10 days by default, which silently capped how far back anything
could ever be reconstructed; `recorder_snippet.yaml` raises that to a year, and a new
`historienimport.py` + "Daten importieren aus Home Assistant" button (both codebases)
rebuilds trips, refuels and battery history for any past window out of that recorder history.
This file is the entry point for every new agent
session: what this repo is, what is finished, what is missing, and how to work here. Detail lives in
the linked documents — this file points, it does not duplicate.

> **Maintenance rule (binding):** whenever you change this repo in a way that affects anything
> recorded here — completing an open item, making or reversing a decision, adding/removing a
> component, discovering a new gap — **update this file in the same session** (tick the checkbox,
> adjust the status table, bump the "Last updated" date). This file must always be written in
> **English**, even though the rest of the project is German.

> **Parity rule (binding, stated by the owner 2026-08-17):** the HA panel
> (`homeassistant/www/audi-dashboard-app.js`) and the companion app (`companion-app/`) must
> **always be at the same functional state**. When a request names only the panel, that is *not* a
> scoping decision — the owner simply tests there because the Docker instance makes it quick to see.
> Port every user-visible change to both in the **same session**, and say so in the report. The two
> are independent implementations (vanilla JS + shadow DOM vs. React + `@audi-dash/ui`), so a port
> is a re-implementation, not a copy: keep the *behaviour and wording* identical, let each side use
> its own idiom, and skip only what genuinely cannot apply (documented per case — e.g. panel-only
> CSS bugs, or HA-panel-only concepts like the entity Setup menu). If a port is impossible or
> unreasonably large, say so explicitly instead of leaving it silently undone. This rule exists
> because the two had already drifted six days apart once (see section A).

**How this file is loaded:** Claude Code does not read `AGENTS.md` natively; the root `CLAUDE.md`
imports it via `@AGENTS.md` (official recommended pattern). Other agents (Codex, Cursor, Copilot)
read `AGENTS.md` directly. Edit content here, not in `CLAUDE.md`.

---

## Working rules

### Karpathy guidelines (adopted as project standard)

From [`multica-ai/andrej-karpathy-skills`](https://github.com/multica-ai/andrej-karpathy-skills),
behavioral rules derived from Andrej Karpathy's observations on LLM coding failures. They bias
toward caution over speed — for trivial tasks, use judgment.

1. **Think before coding.** Don't assume. Don't hide confusion. Surface tradeoffs. State
   assumptions explicitly; if multiple interpretations exist, present them — don't pick silently.
   If a simpler approach exists, say so. Push back when warranted. If something is unclear, stop
   and ask.
2. **Simplicity first.** Minimum code that solves the problem. Nothing speculative: no features
   beyond what was asked, no abstractions for single-use code, no unrequested configurability, no
   error handling for impossible scenarios. If 200 lines could be 50, rewrite.
3. **Surgical changes.** Touch only what you must. Don't "improve" adjacent code, comments, or
   formatting; don't refactor what isn't broken; match existing style. Remove only orphans **your**
   change created; mention (don't delete) pre-existing dead code. Every changed line should trace
   directly to the request.
4. **Goal-driven execution.** Define success criteria, loop until verified. "Fix the bug" → write a
   test that reproduces it, then make it pass. For multi-step tasks, state a brief plan with a
   verify step per item.

### Claude Code practices (current guidance, 2026)

- Keep this file **concise and specific** — instructions concrete enough to verify ("run X before
  Y"), markdown headers/bullets, no dense prose. Context files reduce adherence as they grow;
  target roughly ≤200 lines of actual instruction.
- Don't record here what the code or git history already shows (directory dumps, dependency
  lists) — record pitfalls, rationale, decisions, and conventions that differ from defaults.
- Conflicting instructions get picked arbitrarily — when updating, **remove** superseded rules
  rather than stacking corrections.
- Verify loading with `/context` (file must appear under **Memory files**); edit via `/memory`.
- **This file (plus the session's task list) is the source of truth for what's done — not a leftover
  plan-mode file.** A `~/.claude/plans/*.md` file surfaced by the system as "not yet complete" can be
  stale: plan files aren't marked complete when their work finishes. Before claiming a feature is
  unbuilt (e.g. "the Setup menu isn't implemented yet"), check the `[x]`/`[ ]` items below and/or the
  task list — trust those over an old plan file's own claim about its status. (Caused a real mistake
  2026-08-12: told the user the Setup menu wasn't built yet, when it had shipped hours earlier in
  the same project — see the "Setup menu" `[x]` entry further down.)

---

## What this project is

Private replacement for the Audi *connect plug & play* app (discontinued end of 2026), for exactly
one vehicle (**Audi RS 4 Avant competition**), single user, self-hosted on Home Assistant, access
via Tailscale only. Read-only toward the vehicle — no remote control. Covers: vehicle status, trip
log, fuel log with Shell receipt parsing, service forecasting, insurance/tax, tire management.

**License rule (hard):** Audi Type fonts, four-rings SVG, and model-badge SVGs are cleared **only
for this one private, unpublished installation**. Therefore `design-system/` is brand-free (it gets
uploaded to Claude Design), while the HA panel and the future sideload-only app may use the real
assets. Never mix these the other way around.

**Reading order for a new session:**
1. This file (overview + open items)
2. `UMSETZUNGSPLAN.md` — the step-by-step execution plan for all open items (13 phases with
   commands and acceptance criteria); when working on an open item, follow the plan's phase
3. `SPECIFICATION.md` — authoritative for the HA panel, incl. §7 "Known Gaps"
4. `COMPANION_APP_ARCHITECTURE.md` — authoritative for DataMetric360 (decided, phases 1–10 built)
5. `AUDIT_2026-08-10.md` — accessibility/platform audit of the panel (partly done, rest below)
6. `bauauftrag.md` — **historical only**; code has diverged (see SPECIFICATION.md §7)

## The three projects in this repo

| Area | What | Status |
|---|---|---|
| `custom_components/audi_dashboard/` | The HA integration: backend, panel, OTA app bundle. HACS is not used and cannot be — private repo, categorical refusal on HACS's side, no GitHub mirror any more; `install.ps1` is the permanent, only install path (section H) | ✅ **finished, in use** — replaced the pyscript backend on 2026-08-23 (section H) |
| `homeassistant/` | Install package, recorder snippet, guides — **no code any more** | ✅ |
| `testumgebung/` | Script that rebuilds a throwaway Home Assistant with the real backend | ✅ new, reproducible |
| `design-system/` | React component library `@audi-dash/ui`, brand-free, feeds Claude Design | ✅ done as a kit (20 components, 1,690 lines) |
| `companion-app/` | **DataMetric360** — successor app (web, PWA, native iOS/Android via Capacitor, HA iframe); will **replace** the panel | ✅ **all 21 screens built and tested**; runs natively on iOS with real data |
| `design/` | Export of the Claude Design draft for DataMetric360 | 🚧 interim and now behind the code — screens were derived from the old panel instead (owner's decision) |

Root files: `dashboard-muster*.html` = original static prototype (superseded, reference only),
`bauauftrag.md`/`.html` = original build brief (historical), `DESIGN_BRIEF_DATAMETRIC360.md` = the
prompt for the Claude Design project.

**`custom_components/audi_dashboard/` in one paragraph:** a normal HA integration with a config
flow, one `sensor` platform and 18 services. `koordinator.py` owns the runtime state and the
wiring; the domain modules beside it carry the logic: trip detection via the FMM003 ignition
sensor with pause tolerance (`fahrterkennung.py`), two-stage trip completion via recorder history
(`screening.py` — the odometer often updates only on the next trip), fill-up detection on
fuel-level rise (`tankerkennung.py`), Shell PDF parser (`shell_beleg_parser.py`, in-process via
`pypdf`, the only tested part of the repo), tire km counter, backup, image management, retroactive
history import. `ablage.py` is the only place that touches files. Frontend: `frontend/audi-dashboard-app.js`
(~4,900 lines, custom element, no framework/bundler), 5 tabs + ~19 detail routes, served by the
integration itself under `/audi_dashboard_static/` with the manifest version as the cache breaker.
Entity IDs are assigned **in the UI** (Settings → Fahrzeug einrichten → Setup), stored as overrides
in `/config/audi_dashboard/entitaeten.json`; `einstellungen.py` holds only the built-in defaults
(all empty). Deploy: HACS, or `homeassistant/installationspaket/install.ps1` where HACS can't reach
the repo.

**`companion-app/` — what exists:** the full app. Data layer (`src/api/`: REST, WebSocket with
reconnect backoff, persistent offline write queue, credential storage), domain logic
(`src/daten/`: profile adapter, statistics, service forecast, data context, OTA update flow — see
section H), all 21 screens (`src/screens/`), Audi assets (`src/assets/audi/`), PWA manifest and
icons. Verified by 117 unit and render tests (`npm test`) plus the two smoke suites against a live
Home Assistant (`npm run smoke`, `npm run smoke:auth`). `npm run dev` in the repo root starts it;
`testumgebung/aufsetzen.sh` provides the server side.

**DataMetric360 architecture (short — details in `COMPANION_APP_ARCHITECTURE.md`):**
- Future data source: **Teltonika FMM003** on the CAN bus, fully replacing the iPhone WLAN sensor
  and the VAG integration.
- **Current data path (since 2026-08-12): FMM003 → flespi (native Codec8/TCP, IMEI auth) → HA.**
  Both earlier directions are dropped: Traccar first, then the self-hosted MQTT/TLS broker — the
  router's port-forward turned out to be unusable. The decision logs deliberately remain in the
  architecture doc, marked ÜBERHOLT. Details and open steps in section B below.
- Frontend access: HA REST + WebSocket with a long-lived access token (no `hass` object).
- External access: Cloudflare Tunnel + reverse proxy with a path allowlist; HA itself stays
  unreachable. Domain **`datametric360.app`** registered (all-inkl, 2026-08-11).
- Distribution: **sideload only** (license reason) — hence real Audi assets are allowed there.
- Trip detection moves to FMM003 ignition; the pause-tolerance feature
  (`fahrten_pausenzeit_min`) is a deliberate product decision and must be preserved.

---

## Review findings (2026-08-11)

### Design/CI review of `main` (2026-08-13) — see `DESIGN_REVIEW_2026-08-13.md`

Code review of all UI layers plus headless-browser screenshots of the panel (mock `hass`),
checked against the repo's own Audi-CI rules. Headlines: the iOS overlay
(`audi-dashboard-ios.css`) overrides the documented palette/radii, adds shadows/blur and turns
red into a fill color **without any recorded decision** (visible e.g. as a red-filled "Abbrechen"
next to a black "Speichern" in the setup popup); `design-system/` still carries pre-audit state
(old low-contrast `--fg3 #657081`, no focus styles at all, 13.5px inputs, 34px icon buttons);
the `design/` export's RS-6 error extends into the technical sample data (73 l tank, 285/30 R22,
FIN series 4G). Four browser-verified rendering defects: switches stretched by
`.feld label{flex:1 1 auto}` (state display becomes ambiguous), double "offen" in the trip list,
empty oil-change tile when the service book is empty, and fragmented Leaflet rendering in all
three maps (✅ **fixed 2026-08-16** — root cause was Leaflet's stylesheet being loaded into
`document.head`, which never reaches the panel's shadow root; see the third-round entry in
"Open items"). Positives: the 300/400 font-weight rule and de-DE
formatting hold everywhere; `design-system/` is verifiably brand-free.

### HA panel — known gaps (most also in SPECIFICATION.md §7)

- **GPS is dead schema for trip records specifically:** `start_lat/lon`, addresses, `route`,
  `avg_speed_kmh` on individual trips never populated. The trip-detail map draws a **fabricated**
  line via `fakeTrack()` (`audi-dashboard-app.js:414`) — not a real track. (Unrelated: the
  Übersicht's live Standort-Kachel is no longer in this state — `STANDORT_TRACKER` now points at
  a real `device_tracker`, see section B/C. This item is only about per-trip route data, which
  nothing currently populates.)
- **No GPS fallback in trip completion:** trips without an odometer match stay `status="offen"`
  forever (`modules/fahrtabschluss_logik.py:16-20`).
- **RAM-only state:** running trip (`_fahrt_start_ts`) and fuel low-water-mark
  (`_tiefststand_pct`) do not survive an HA restart. Deliberately deferred hardening.
- **Inactive features:** `UPDATE_REPO_URL = ""` (self-update inactive) in
  `pyscript/modules/einstellungen.py`. `www/bilder/` has no vehicle photos (all slots show
  placeholders); `steuer.faellig` unset. (`BATTERIE_SENSOR` was the same kind of gap until
  2026-08-12 — now wired to the FMM003's `external_power_voltage`, see section B/C below. Several
  *other* fields are newly inactive since the same date for a different reason — the
  `TommiG1/HA_VAG-EU-Data-Act` integration they depended on was dropped: `KM_SENSOR`,
  `TANK_SENSOR`, `RANGE_SENSOR`, `REFRESH_BUTTON`, all door/window/lock sensors, oil-change/
  inspection sensors. All are configurable again via the new Setup menu, see section C.)
- **Robustness:** `profil_lesen()` in `modules/profil.py` does not handle a missing/corrupt
  `fahrzeugprofil.json` — all callers throw.
- **Tests:** only the receipt parser is tested (`data/tests/test_shell_beleg_parser.py`, 10 real
  receipts) — and its test PDFs are gitignored, so a fresh clone can't run it. Backend and
  frontend: no tests, no CI.
- **Leaflet via CDN:** trip map needs public internet in addition to the Tailscale tunnel.

### Documentation drift ✅ FIXED 2026-08-11 (kept as a record of what was wrong)

- `homeassistant/README.md:99`, `INSTALL.md:204`, and the header comment
  `audi-dashboard-app.js:13-15` claim the statistics view shows sample numbers — **false**;
  `vStat()` computes real values from `TRIPS`/`FILLS`.
- `homeassistant/README.md:110` mentions the removed 97% full-tank rule (removal documented in
  `belegverarbeitung.py:18-20`); the README file list omits 5 pyscript files.
- Obsolete comment `belegverarbeitung.py:41` ("TODO: Datei ablegen" — file has long existed).

### Audit leftovers — two of three now fixed in the new app

- ✅ Swipe-to-delete without a gesture-free fallback — **fixed in the app**: every list row also
  carries an always-visible "…" menu (`companion-app/src/screens/Zeilenmenue.tsx`). Still open in
  the old panel, which is being replaced anyway.
- ✅ Leaflet from a CDN — **fixed in the app**: bundled from node_modules as a lazy chunk.
- 🟡 Popup close-by-tap-outside in the old panel: still unchecked (the app uses the library's
  Popup, which is keyboard-reachable).
- Audit's own note: these three may be better done in DataMetric360 than retrofitted — the panel
  gets replaced anyway.

### companion-app / design-system ✅ RESOLVED 2026-08-11

- **Not wired together** — fixed: an npm workspace in the repo root links `@audi-dash/ui` into the
  app as a real dependency.
- Both packages: no `node_modules`, no `dist` — smoke tests need `npm install` first
  (design-system additionally `npm run build`; `scripts/smoke.mjs` imports from `../dist/`).
- No unit tests, no Storybook (substitute: SSR smoke over 21 cases in design-system).
- `design/` export (2026-08-11) has two known, already-commissioned fixes not yet in the export:
  shows **RS 6 instead of RS 4**, and **16 sub-pages** the HA panel already has are missing
  (list in `design/README.md`).

---

### Found while building (2026-08-11) — all fixed

Three defects that only surfaced by running against a real Home Assistant, not by reading code:

1. **The data layer declared field names the backend never sends.** `Fahrzeugstatus` had
   `tank_prozent`/`sicher_abgestellt`/`sicherheit`; the backend writes
   `tankprozent`/`gesichert`/`sicherheitscheck`. Every screen would have read `undefined` without
   anything failing. `technik`/`ausstattung` were typed as objects but are arrays.
2. **The profile adapter handed out live references into the raw profile.** Editing a form would
   have silently mutated the baseline and broken the promise never to overwrite the
   backend-maintained tire odometer. Sections are copied now.
3. **`design-system` still carried the pre-audit `--fg3: #657081`** (3.0:1 on `--tile`, fails WCAG
   AA) that the panel had already fixed to `#8a94a3`. The new app would have inherited a
   already-solved contrast defect.

Also: two TypeScript parameter properties in the data layer broke Node's strip-only mode, which is
what the smoke scripts run on — rewritten as plain fields.

Three more that only screenshots revealed — nothing failed, the pixels were simply wrong:

4. **The vehicle block on the home screen was invisible.** `overflow: hidden` sets a flex item's
   automatic minimum size to 0, so once the page was taller than the screen, flex-shrink squashed
   the block to zero height and took model name, badge and plate with it. Fixed with
   `.dm-inhalt > * { flex: none }`.
5. **The model name appeared twice** — once as the badge image, once as text beside it.
6. **Filenames overflowed the gallery thumbnails.**

And one in the backend, found by watching the log against a current Home Assistant: the odometer
screening called `urlopen` directly, which HA aborts as a blocking call since 2026.8. Every trip
stayed without a distance, visible only as a warning. Now runs through `task.executor`.

**CORS is a real constraint for this app.** `cors_allowed_origins` did not take effect on HA
2026.8 (preflight 403 even same-origin). Two consequences, both handled: the web build is served
from Home Assistant itself (`/local/dm360/`, same origin — the planned deployment anyway), and the
native hull enables `CapacitorHttp`, which routes fetch through native HTTP where CORS does not
apply. Verify this again at commissioning if the app ever moves to a separate hostname.

**A self-written QR encoder was discarded.** It disagreed with a reference implementation on 1239
of 3249 modules — the code would have been unreadable. `homeassistant/www/dm360-qr.html` now uses a
vendored MIT library served from Home Assistant itself, which satisfies the actual requirement
(no network call, token never leaves the local network) and round-trips correctly.

## Pending: this branch has diverged from `main` (noted 2026-08-13)

`ha_install.md` (root) planned the move from pyscript to a **native HA integration** with a config
flow — UI-only setup, no YAML. **That move happened on 2026-08-23; see section H.** The plan
document is now historical: it still argued for a self-reporting `UpdateEntity` against the Gitea
repo because HACS is GitHub-only, and that part was dropped — the built-in updater was removed
rather than reimplemented, and updates go through HACS (or the install script where HACS cannot
reach the repo). All APIs in it were verified against the running 2026.8.1 instance. It also
records which review findings that move eliminates by design.

A full review of `main`'s 18 new commits is in `REVIEW_main_2026-08-13.md` — 15 findings, the
three most serious in the new setup menu (saving with an unloaded catalogue wipes the whole
mapping; "reset" has no effect on 15 of 17 fields; all four list positions get the same sensor,
which makes "securely parked" report safe while three doors were never checked).

`main` has moved 18 commits ahead of `umsetzung-datametric360` (FMM003 switch, sensor-mapping setup
menu, iOS/large-screen overlay from Claude Design). **Deliberate decision: do not merge yet** — the
owner keeps working on `main` first.

Three files conflict (`AGENTS.md`, `homeassistant/INSTALL.md`,
`homeassistant/pyscript/fahrterkennung.py`); four more are touched by both sides but merge cleanly.

**One hazard that a clean merge will not catch.** This branch changed `profil_lesen()` in
`homeassistant/pyscript/modules/profil.py` to return `None` when the profile file is missing or
corrupt, and guarded all seven call sites that existed here. `profil.py` is untouched on `main`, so
it merges silently — but `main`'s call sites (`fahrterkennung.py`, `tankerkennung.py`,
`reifenzaehler.py`, `modules/frontend_veroeffentlichung.py`) do **not** guard against `None` and
would raise `AttributeError` on a missing profile instead of logging a clear error.

When merging: take `main`'s FMM003 version of `fahrterkennung.py`, then re-apply the `None` guard to
every remaining `profil.profil_lesen()` call site.

## Open items

Execution order, exact steps, and acceptance criteria for every item below live in
`UMSETZUNGSPLAN.md` (phases 1–13). Additional decisions of 2026-08-11: **no Electron** (Capacitor
wraps the web app for iPhone; a PWA home-screen install is the accepted intermediate step) and
**no separate backend** (the app talks to the HA REST/WebSocket API directly).

### A) Build DataMetric360 (the big block)

- [x] Fix the Claude Design draft (RS 4, not RS 6) and extend it by the 16 missing sub-pages;
      then re-export to `design/`
- [x] `companion-app`: set up Vite + React + Capacitor scaffold; wire `@audi-dash/ui` as a real
      dependency (possibly add a workspace/monorepo root)
- [x] Implement the screens from the design draft on top of the existing `DataMetricApi` layer
- [x] Add Audi assets (fonts/rings/badges) at implementation time from `homeassistant/www/` —
      **never** into `design-system/`
- [x] Secure storage for the LLAT (iOS Keychain / Android Keystore via Capacitor plugin; the
      `ablageSetzen()` hook already exists)
- [x] Onboarding: manual token paste (required); QR scan only if it stays simple (QR generated
      locally under HA `/local/`, architecture §3)
- [x] Authenticated smoke tests of the data layer (reads, service calls, queue round-trip)
      against `audi_ha_test` with a real token
- [x] Offline UX per design brief (offline marker, visible pending queue)

### B) Infrastructure / commissioning (partly waits for FMM003 hardware)

- [ ] Switch `datametric360.app` nameservers at all-inkl to Cloudflare ("full setup") —
      prerequisite for the tunnel; domain carries nothing else, so this is consequence-free
- [ ] Decide hostname split (app on apex + API on `api.` subdomain, or vice versa)
- [ ] Choose reverse proxy (Nginx Proxy Manager vs. Traefik) — **can be done before hardware**
      against the existing HA API
- [x] Define the reverse-proxy path allowlist (depends on final entity/service names)
- [ ] Install/wire the FMM003; record firmware version (Codec JSON is firmware-dependent)
- [x] Generate TLS certificates for Mosquitto + device (small private CA) — done 2026-08-11, 10-year
      validity; Mosquitto configured (`certfile`/`keyfile`/`cafile`/`require_certificate: true`).
      Remaining: upload root/client cert/key to the FMM003 Security tab — filenames must end in
      `.pem`/`.pem.crt`/`.pem.key` (Configurator rejects plain `.crt`/`.key`, content-agnostic check)
- [ ] Decide broker reachability for the vehicle — **reopened 2026-08-11 evening**: port-forward
      8883 on the Speedport Smart 4 Plus looked correctly configured (rule present, right internal
      IP, right port) and internal reachability was confirmed (`homeassistant.local:8883` open from
      the LAN, Mosquitto TLS listener genuinely up, TLS cert chain end-to-end verified byte-for-byte
      against the FMM003's uploaded client cert), but the port stayed **closed from outside**
      (confirmed via external port checker, both before and after a router reboot that changed the
      dynamic WAN IP — DNS/DuckDNS matched correctly each time, so not a DNS or CGNAT issue).
      Community reports (ComputerBase, Telekom Hilft) describe this as a known, Telekom-acknowledged
      firmware bug on this router model; a full disable-the-firewall workaround doesn't exist on this
      model either. Port-forward rule has been removed again. **New direction: route the FMM003
      through flespi instead** (native Teltonika/Codec8 channel, IMEI-based auth, no certs, no
      inbound port needed at all — flespi has a stable public endpoint; HA pulls data back out via
      flespi's REST API or MQTT, outbound-only). Free flespi tier (10 devices/2 channels) is enough
      for one vehicle. Next step: user creates the flespi account + Teltonika channel; a `flespi`
      custom integration is already present in this HA instance (unconfigured) — check what it
      needs once flespi-side setup exists.
      Unrelated but still valid from the same session: DuckDNS hostname
      `datametric360.duckdns.org` reliably updating; Let's Encrypt for HA's own local UI works (root
      cause of the earlier DNS-01 failures was a stray `aliases` entry in the DuckDNS add-on config,
      not DNS/network — see COMPANION_APP_ARCHITECTURE.md §5 item 2a). HA's SSL config now lives in
      Settings → System → Network (UI), not `configuration.yaml`'s old `http:` block, which was
      removed after HA started migrating/ignoring it. The Mosquitto TLS setup itself (cert chain,
      `require_certificate: true`, `certfile`/`keyfile`/`cafile`) is verified correct and can be
      reused as-is if a self-hosted broker is ever revisited.
- [ ] MQTT Client Type on the FMM003 ("Custom server" not selectable in practice, "AWS IoT Custom"
      pointed at a self-hosted broker was confirmed working by two independent community reports) —
      **moot for now** given the flespi pivot above: flespi uses the device's native Codec8/TCP
      channel, not MQTT at all, so Server Settings should switch to Protocol: TCP against the flespi
      channel host/port instead, and Codec set to "Codec 8 Extended". Revisit this item only if a
      self-hosted broker is picked back up later.
- [ ] Capture the first real Codec JSON message (`mosquitto_sub`/MQTT Explorer) and build the
      field mapping from it — **do not guess beforehand** (explicit decision)
- [x] Move trip detection to FMM003 ignition — done 2026-08-12. `fahrterkennung.py` rewritten:
      trigger is now `einstellungen.ZUENDUNG_SENSOR`
      (`binary_sensor.testzone_fmm003_engine_ignition_or_acc_status`, on = trip running) instead
      of the iPhone WLAN sensor; same `task.unique()` pause-tolerance pattern kept unchanged.
      `sensor.iphone_wifi_connection` and the `fahrzeug.wlan_name` profile field are fully removed
      (backend and frontend). At the same time, all entity IDs sourced from the now-abandoned
      `TommiG1/HA_VAG-EU-Data-Act` HACS integration were blanked in `einstellungen.py`
      (`KM_SENSOR`, `TANK_SENSOR`, `RANGE_SENSOR`, `REFRESH_BUTTON`, door/window/lock sensors,
      oil-change/inspection sensors) — the consuming features (Übersicht tiles, Reifenzähler,
      automatic Tankerkennung, Fahrtabschluss-Screening) are untouched and degrade gracefully to
      "unbekannt"/inactive per the existing `zustand_oder_none()` convention, rather than being
      deleted; `fahrtabschluss.py`/`tankerkennung.py`/`reifenzaehler.py` now guard their
      `@state_trigger(f"{einstellungen.KM_SENSOR}")`-style decorators with
      `if einstellungen.KM_SENSOR:` (untested previously whether pyscript tolerates an empty
      trigger string — not worth relying on). `STANDORT_TRACKER` now points at the real
      `device_tracker.testzone_fmm003` and `BATTERIE_SENSOR` at
      `sensor.testzone_fmm003_external_power_voltage` (the vehicle's 12V bus voltage as measured
      by the tracker — NOT `sensor.testzone_fmm003_battery_voltage`, which is the tracker's own
      internal backup cell and unrelated to the car). Real entity list for this device: the user's
      `Entiaeten.csv` export (OneDrive, not in this repo). Verified in `audi_ha_test`: pyscript
      reload (`pyscript.reload`) produced no errors/warnings for any of the changed files; the
      Übersicht correctly shows "Status unbekannt" and em-dash placeholders for the now-blank VAG
      fields instead of crashing (that container itself has no `device_tracker.testzone_fmm003`
      registered, so Standort still shows "Kein GPS-Signal" there — expected, the real HA instance
      has it). `INSTALL.md` step 4 and its troubleshooting table have since been rewritten
      (2026-08-12) to point at the Setup menu instead of manual `einstellungen.py` edits, and to
      use the current FMM003 field names (`ZUENDUNG_SENSOR`/`STANDORT_TRACKER`/`BATTERIE_SENSOR`)
      instead of `WLAN_SENSOR`/TommiG1 — not live-tested against a real install (INSTALL.md itself
      was never run end-to-end in this project, per its own opening note), but consistent with the
      actual code.

### C) Maintain the existing HA panel (low priority — being replaced)

- [x] Wide-screen/desktop layout (`@container (min-width:860px)` in `audi-dashboard-ios.css`,
      turns the same `.tabbar` into a 264px side-nav via CSS only, no JS/markup duplication) — done
      2026-08-11 from the Claude Design "DataMetric360 Board" draft (`claude.ai/design` project
      `c28a8d4d-ec4e-4178-9e49-ab5b90c02097`). Found and fixed a real regression while verifying it
      live in `audi_ha_test`: the overlay's `:host > div{height:100%}` (meant to drop the old
      412px-cap for the sidebar layout) was applied unconditionally instead of only inside the
      `@container` block, which broke the **normal mobile layout too** — HA doesn't reliably
      propagate a real height down through `panel_custom`, so `height:100%` collapsed `main#view` to
      ~0px (tabbar rendered right under the header, content invisible). Fix: kept the outer wrapper
      viewport-anchored (`min(880px, calc(100dvh - 24px))`, matching `audi-dashboard.css`'s original
      reasoning) at all widths, and only drop the 880px cap (`calc(100dvh - 24px)`, still
      viewport-anchored, not parent-relative) inside `@container (min-width:860px)`. Verified live at
      both narrow (mobile tab bar, full content) and wide (1144px container, grid side-nav,
      navigation clicks, settings back-arrow) — screenshots taken, no regressions found.
- [x] Desktop-Sidebar-Markenklon (`.navmarke`) nachgezogen — done 2026-08-12. The user compared the
      deployed app against the live Claude Design "Board" (the two-viewport comparison page, which
      genuinely iframes this project's own `audi-dashboard-app.js`/`-ios.css`, not a static mockup)
      and asked why the desktop Audi-rings placement differed: Design showed the rings pinned above
      the sidebar nav list, ours showed them in the content header next to the title. Root cause,
      confirmed via `DesignSync get_file` + byte-diff: `audi-dashboard-app.js` was already fully in
      sync (identical), but `audi-dashboard-ios.css` had drifted — the Design project had since
      added a `.navmarke` sidebar clone of the rings (only visible in the `@container
      (min-width:860px)` block; `.rings{display:none!important}` hides the header copy there so the
      mark appears once) plus a filled-pill active-tab style (`.tabpille`), neither pulled back
      locally. Diffed the two full files first to confirm every change was additive/CSS-only, then
      replaced the local file wholesale. Verified in `audi_ha_test` at both 402px and 1400px: rings
      now sit above the nav list on desktop, gear icon alone in the content header, mobile view
      unaffected, no console errors beyond the pre-existing missing-vehicle-photo 404 gap.
- [x] Standort-Kachel (live vehicle GPS position on the Übersicht, above "Zuletzt") — done
      2026-08-12. New tile with a real (non-fake) Leaflet mini-map, opens a fullscreen "Standort"
      route on tap; fullscreen has 4 floating controls (map style, center-on-vehicle,
      center-on-user, fit-both) and a draggable myAudi-style bottom sheet (peek/expand/close by
      drag or tap, opens on vehicle-marker click) showing distance-to-user, address (client-side
      reverse geocoding via the public Nominatim API — same "public API, no key, low single-
      vehicle volume" reasoning as the existing Leaflet-CDN gap below), fährt/steht/"Letzter
      Parkplatz" state, fuel/range, and Route (Google Maps deep link) / Teilen (Web Share API)
      actions. Backend: new `STANDORT_TRACKER` entity-ID setting in `einstellungen.py` (device_
      tracker with lat/lon attributes), published via a new `_standort()` helper in
      `frontend_veroeffentlichung.py`. The built-in default points at the author's test device
      (`device_tracker.testzone_fmm003`); until the FMM003 actually delivers positions (section B),
      that entity does not exist and the tile shows "Kein GPS-Signal vom Fahrzeug". Verified end-to-end in `audi_ha_test` with a manually
      created `device_tracker.test_fahrzeug` test entity (tile, fullscreen map, and menu all
      confirmed rendering/populating correctly); the device's own location (browser Geolocation
      API) and the Route/Teilen deep links work independently of the vehicle-GPS gap. Pushed to
      the Claude Design project (`c28a8d4d-ec4e-4178-9e49-ab5b90c02097`, "DataMetric360 Board")
      for visual refinement — the JS/CSS ship functionally complete but visually plain
      (placeholder vehicle-marker glyph, default `.aktion`/`.tile` styling); Claude Design's job is
      polish, not structure. One known rough edge: reverse-geocoded address didn't resolve within
      a few seconds in the `audi_ha_test` container (no crash, stays on "Adresse wird ermittelt …"
      indefinitely) — likely just that container's outbound network to Nominatim specifically;
      unconfirmed whether this reproduces on the real HA instance.
- [x] Setup menu (Einstellungen → Fahrzeug einrichten → last item when expanded) — done
      2026-08-12. Lets the user map every sensor role the app uses (~18 roles, 3 of them 4-item
      door/window/lock lists) to real HA entities from a searchable dropdown, instead of hand-
      editing `einstellungen.py`. New backend module `pyscript/modules/entitaeten.py`: a
      `FELDER` catalog (label, hint, expected domain/device_class/unit, keywords, whether it's
      one of the trigger-bound fields) plus JSON override I/O (`data/entitaeten.json`, same
      atomic-write pattern as `profil.py`) and `overrides_anwenden()`, which does
      `setattr(einstellungen, key, value)` on the already-imported `einstellungen` module — every
      existing consumer does `import einstellungen` + live attribute access (verified: none use
      `from einstellungen import X`), so this needs no changes to any consumer file. New service
      `audi_dashboard_entitaeten_schreiben` (`frontend_api.py`) and publication
      `entitaeten_veroeffentlichen()` (`frontend_veroeffentlichung.py`) follow the project's
      existing read-via-state/write-via-service pattern. Frontend: new popup (`vSetupPopup()`,
      modeled on the existing `.sheet` pattern) with a from-scratch searchable-combobox component
      (none existed in the repo before) — client-side only, built directly from `HASS.states`, no
      new backend read service; a global "Nur passende Sensoren anzeigen" switch hard-filters
      candidates by domain/unit, a keyword+domain+unit score ranks and auto-suggests for empty
      fields. Deliberately does NOT auto-apply changes for the three trigger-bound fields
      (`ZUENDUNG_SENSOR`/`KM_SENSOR`/`TANK_SENSOR` — `@state_trigger` bakes the entity ID in at
      module-load time) — the UI shows an inline "wirkt erst nach Neustart" hint instead of
      attempting a risky self-`pyscript.reload()` from inside a running service call. Two real
      bugs found and fixed during live verification, both now also pushed to the Claude Design
      project: (1) a full popup re-render on every keystroke fought the search input for focus —
      fixed by patching just the candidate-list DOM node on `input`, matching the same fix the
      existing `data-tankpreisfeld` handler already uses for the identical reason; (2) the popup
      background used `--tile-2`, which Design had since changed to a translucent
      `rgba(255,255,255,.1)` in the night theme (for the frosted-glass tile look elsewhere) — made
      the whole popup partially see-through against the app content behind it. Fixed by switching
      to `--tile-deckend` (Design's own opaque-surface token, already used by `.standortmenu` for
      the same reason), with `var(--canvas)` as a defensive fallback.
- [x] Fresh-install packaging + `update.ps1` fix — done 2026-08-12. While preparing a short
      quick-install checklist, found that `update.ps1` never copied `audi-dashboard-ios.css` or
      `www/badges/*` — both are loaded by the running app (the ios overlay is injected by the app
      itself via JS, badges by the model picker since item #51), so every `update.ps1` run since
      those were added silently left the deployed copy stale on those two. Fixed (script now copies
      both). Also removed two stray `*.bak-before-audit-merge` files from `www/` (already gitignored
      at repo root, never committed, just local clutter from an earlier merge). New
      `installationspaket/` — a generated, gitignored bundle (pyscript/, www/ minus the two backup
      files, data/ with only the example profile + receipt parser, `configuration_snippet.yaml`,
      `update.ps1`) plus a short `ANLEITUNG.md` checklist for copying to a fresh HA instance;
      `INSTALL.md` stays the authoritative, detailed reference. Regenerate on demand, don't keep it
      permanently in sync — it's a deployment snapshot, not a second source of truth.
- [x] Setup-menu polish: 5 additions on top of the existing Setup popup — done 2026-08-12.
      Backend (`pyscript/modules/entitaeten.py`): a `_STANDARDWERTE` snapshot taken at module-load
      time (before `overrides_anwenden()` ever runs), exposed via `aktueller_stand()` as
      `standardwerte` — needed because `setattr()` on the live `einstellungen` module is permanent
      for the process, so "reset to default" has to write the original value back explicitly rather
      than just omitting the override. New service `audi_dashboard_neustart` (`frontend_api.py`)
      wrapping `homeassistant.restart()`, called only from an explicit user-confirmed button, never
      automatically. Frontend (`audi-dashboard-app.js`): (1) live current-value preview next to each
      candidate in the search dropdown (`entitaetZeilenMarkup`, reads `HASS.states[e.id].state`);
      (2) inline warning under a field if its currently assigned entity is `unavailable`/`unknown`/
      missing (`entitaetStatusWarnung`); (3) duplicate-assignment check on save
      (`setupDuplikate`) — blocks with a confirm-sheet ("trotzdem speichern?") rather than silently
      allowing the same entity in two roles; (4) per-field reset button (only shown when the current
      value differs from `standardwerte`), reverts to the built-in default; (5) after saving, if any
      of the 3 trigger-bound fields actually changed (`setupGeaenderteTriggerFelder`, compares
      against a snapshot taken when the popup opened), a confirm-sheet offers "Jetzt neu starten"
      calling the new service. Verified live in `audi_ha_test`: value preview and unavailable-warning
      confirmed correct (`ZUENDUNG_SENSOR`'s configured entity genuinely doesn't exist in this test
      container → "Entität nicht gefunden." shown as designed); duplicate-check correctly caught both
      a deliberately-forced KM/TANK collision and pre-existing door/window/lock auto-suggestion
      collisions (a real side effect of this container's limited matching entities, not a bug);
      reset button appeared/disappeared correctly as values diverged from/matched the default;
      save round-tripped through the real service into `data/entitaeten.json` (byte-inspected) and
      into the published `pyscript.audi_dashboard_fahrzeugstatus` state; restart-needed sheet named
      exactly the changed trigger-bound field(s) and no others. Did not actually trigger a restart
      during verification (would have restarted the shared test container) — the service call itself
      was left unexercised beyond confirming it registers without error at pyscript load time.
- [x] Decide the iOS-vs-Audi-CI conflict from `DESIGN_REVIEW_2026-08-13.md` §A — decided
      2026-08-13, owner's call: **iOS look made official** (Option 1 of the two the review offered).
      `SPECIFICATION.md` §2 rewritten to describe the iOS values (colors, 16px radius) as canonical;
      `bauauftrag.md` §8 now counts as superseded (left untouched, per this project's convention of
      never editing that file — see its own "historical only" note above). The pre-decision Audi-CI
      spec text is preserved verbatim in [`AUDI_CI_ARCHIV_2026-08-13.md`](AUDI_CI_ARCHIV_2026-08-13.md),
      which also documents *why reverting later needs no restore*: `audi-dashboard-ios.css` is a
      purely additive overlay loaded after `audi-dashboard.css` in `_aufbauen()`
      (`audi-dashboard-app.js` ~line 3990) — the Audi-CI base values are still fully intact in the
      unmodified base stylesheet, untouched by this decision. Reverting is just: stop loading the
      overlay `<link>`.
- [x] Fix the two implementation bugs from the same review, independent of the direction decision
      above — done 2026-08-13. **§B, red-semantics inversion:** `audi-dashboard-ios.css` `.aktion`
      no longer fills every action red; base `.aktion` is now a neutral `--ios-fill` secondary
      button, a new `.aktion.primaer` rule is the only one filled with `--ios-tint` (red), matching
      the `.aktion`/`.primaer`/`.loeschen` semantics the base stylesheet already defined (outline
      secondary / filled primary / destructive). Also fixed as part of the same finding: switch
      on-state now uses `--ok` (iOS system green) instead of red; the desktop-sidebar active-tab
      background (`.tab.on`) is now the neutral `--ios-fill` instead of a red tint, red stays only
      as the accent text/icon color; two chart-bar segments in `audi-dashboard-app.js` that used
      `--red` for a neutral data category ("Arbeitsweg" trips, first segment of the insurance
      contribution bar) now use `--fg` like their sibling segments. **Radius inconsistency:**
      `.setup-popup` and `.standortmenu` in `audi-dashboard.css` now reference `var(--r-tile)`
      instead of a hardcoded `20px` literal, so both track whichever design (iOS 16px or, if
      reverted, Audi-CI 20px) is actually active. Verified live in `audi_ha_test`: Setup-popup
      Abbrechen/Speichern and the "Einrichten"/"Setup"/"Fertig" buttons in Einstellungen render with
      the corrected fills at both mobile and desktop widths, switches render green, no console
      errors.
- [x] Clean up remaining "EU Data Act" residue from the FMM003 pivot (2026-08-13) — the
      Einstellungen "Version" tile in `audi-dashboard-app.js` hardcoded `Fahrzeugdaten: EU Data
      Act / Abruf alle 15 Minuten` and `Position: iPhone / Companion App`, both stale: vehicle data
      and position now come exclusively from the FMM003 (`ZUENDUNG_SENSOR`, `BATTERIE_SENSOR`,
      `STANDORT_TRACKER` in `einstellungen.py`), not the abandoned HACS integration or the iPhone.
      Both rows now read `FMM003`. Also fixed `testumgebung/konfiguration.yaml` +
      `testumgebung/README.md`: the `input_text.test_wlan` / `iPhone WiFi Connection` template
      sensor simulated WLAN-based trip detection, fully removed 2026-08-12 (`fahrzeug.wlan_ssid` no
      longer exists anywhere) — its "Fahrt auslösen" example was non-functional. Replaced with
      `input_boolean.test_zuendung` driving a `binary_sensor.testzone_fmm003_engine_ignition_or_acc_status`
      template sensor matching the real `ZUENDUNG_SENSOR` entity ID, so trip detection stays
      testable. The remaining KM/Tank/Range/door/window/lock template sensors correctly still
      describe the abandoned `TommiG1/HA_VAG-EU-Data-Act` integration (per `einstellungen.py`) and
      were left in place — those fields are blank by default but stay manually mappable via the
      Setup menu, so the fixtures remain useful for testing that path; comments updated to make
      this distinction explicit.
- [x] Fix oversized toggle switches ("Slider") across the app (2026-08-13) — `.feld label` in
      `audi-dashboard.css` (`flex: 1 1 auto`, meant to let the *text* label grow so the control
      lands at the row's right edge) unintentionally also matched `<label class="switch">`, since
      the toggle switch is itself implemented as a `<label>` — its own `.switch { flex-shrink: 0;
      width: 46px }` rule lost because `.feld label` has equal-or-higher specificity and comes
      later in the file. Every switch inside a `.feld` (Einstellungen, Tanken, Reifen, ...)
      stretched to fill the row (~260-300px measured live, instead of 46-51px). Fixed by excluding
      switches from the selector: `.feld label:not(.switch)`. Verified live in `audi_ha_test` at
      mobile (375px) and desktop widths — switches render at the correct ~51px iOS size, no
      console errors.
- [x] Fix desktop (≥860px container) layout audit findings (2026-08-13, see
      `DESIGN_AUDIT_2026-08-13.md` for full detail) — user-reported: (1) the settings gear button
      sat far past the right edge of the visible content column on wide screens because `.topbar`
      (grid-column:2, same track as `main#view`) had no `max-width:860px` while `main#view` did —
      the track itself is `minmax(0,1fr)` and stretches to the full remaining window width, so
      `.head{flex:1}` pushed `.profilbtn` to the track's far edge, ~450px past where `main#view`
      visually ends. Fixed by giving `.topbar`/`.phone:has(.back.on) .topbar`/`.ptr` the same
      `max-width:860px`. (2) In phone width the Audi rings (`#marke`/`.rings`) are, per existing
      code comment, the *only* access to Einstellungen (the gear icon is hidden there) but were
      42×24px — well under the 44px minimum touch target every sibling icon-button uses. Bumped to
      58×32px. (3) Found while verifying (1): `.setup-popup`, `.sdpopup`, `.sheet`, and
      `.standortmenu` are all `position:absolute` with `left`/`right` relative to the *full*
      `.phone` box (sidebar included), not the content column, and had no desktop max-width either
      — `.setup-popup` measured 1644px wide on a 1920px window (10px margins only). Fixed with
      `max-width:560px` (`.setup-popup`/`.sdpopup`/`.sheet`) or `640px` (`.standortmenu`) plus
      `margin-left/right:auto` in the same container query — deliberately not
      `transform:translateX(-50%)`, which would fight these elements' own transform-based entry
      animations. Centering is relative to the full `.phone` width (sidebar included), not the
      860px content column exactly — a ~138px rightward offset from true content-center remains at
      1920px; fixing that precisely would require moving these overlays into grid-column 2 in the
      markup, deferred as a larger structural change. All three verified live in `audi_ha_test` at
      375px and 1920px, no console errors.
- [x] Fix 4 more user-reported design findings (2026-08-13, same session, see
      `DESIGN_AUDIT_2026-08-13.md` "Runde 2" for full detail with before/after measurements):
      (1) page title sat ~30px right of the content tiles below it on wide screens — `.back`
      reserves 44px+gap in `.topbar` even when inactive (`opacity:0`, not `display:none`), fixed
      with `.back:not(.on){width:0;...}` in the desktop container query, root pages only (detail
      pages with `.back.on` unaffected, verified back-navigation still works). (2) the chevron on
      the 5 `.go`-tiles (Fahrzeug/Service/Versicherung/Reifen/Schutzbrief on Mein Audi/Versicherung)
      was vertically centered on the *entire* multi-row tile (`top:50%` in the iOS overlay) instead
      of next to the title, overlapping wrapped row text — removed the override, falls back to the
      base `top:var(--sp-5)` next-to-title position. (3) the Ölwechsel-Intervall `<select>` values
      ("10.000 km"/"1 Jahr") looked like inert text — iOS overlay strips border/background from
      `.feld select` and colors it the same grey as any other secondary text; added
      `.feld select{color:var(--ios-tint)}` so picker fields read as editable the way iOS
      conventionally signals it (accent color), free-text `.feld input`s deliberately left alone.
      (4) the expand/collapse `.mark` chevron on group headers (Fahrten "2026"/"August") looked
      like a checkmark — it uses the classic "2 borders + rotate(45deg)" CSS chevron technique,
      which requires a square box; `.mark` was 6×10px (non-square, sized for the unrelated SVG-path
      `.chev`/`.go` icons), made it 8×8px. Two more reported findings ("grey box around all menu
      icons", "red partial frame on Fahrten/Tanken rows") were investigated (computed-style
      diffing across all 5 tabs in both themes, a temporary 2.5x `transform:scale()` zoom on the
      live swipe-row DOM) but could not be reproduced — documented in the audit file as open,
      pending a screenshot from the real device.
- [x] Fix five user-reported bugs (2026-08-16):
      (1) **Türschloss-/Haubenschloss-Erkennung entfernt** — user asked for these two checks to be
      permanently removed (not just left blank/unmapped like the rest of the retired VAG-
      integration fields). Removed `TUERSCHLOSS_SENSOREN`/`HAUBENSCHLOSS_SENSOR` from
      `entitaeten.py`'s `FELDER` catalog (no longer offered in the Setup menu) and from
      `einstellungen.py` (attributes deleted, not just blanked), and removed their entries from
      `_sicherheitscheck()` in `frontend_veroeffentlichung.py` (no longer listed under "Geprüfte
      Punkte"). `HECKKLAPPENSCHLOSS_SENSOR` (tailgate lock) was deliberately left untouched — user
      named only the two door/hood locks. Also removed the now-orphaned
      `input_boolean.test_entriegelt` helper and the four `test_audi_lock_*` template sensors from
      `testumgebung/konfiguration.yaml` (door-open/window fixtures for `TUER_SENSOREN`/
      `FENSTER_SENSOREN` are untouched), and updated `testumgebung/README.md` accordingly.
      (2) **"Doppelt zugeordnet" (and every other confirm-sheet) was see-through** — `.sheet-gruppe`/
      `.sheet-abbrechen` in `audi-dashboard.css` used `--tile-2`, which the iOS overlay's night
      theme redefines as `rgba(255,255,255,.10)` (translucent, meant for tile-on-tile layering) —
      the same root cause already fixed for `.setup-popup`/`.standortmenu` on 2026-08-13, just not
      carried over to the generic confirm-sheet used by `bestaetigen()`/`hinweis()`. Switched both
      to `var(--tile-deckend, var(--canvas))`, matching the existing fallback pattern already used
      elsewhere in this file (base CSS must not go blank if the purely-additive iOS overlay is ever
      reverted, since `--tile-deckend` is an overlay-only token).
      (3) **FMM003 coordinates arrive as two separate sensors, not one device_tracker** — user
      reported "coordinates are split into latitude and longitude". Root cause: `_standort()` in
      `frontend_veroeffentlichung.py` only ever read `latitude`/`longitude` as *attributes of a
      single `STANDORT_TRACKER` device_tracker entity* (HA's usual convention) — but some
      integrations (confirmed live in `audi_ha_test`'s own simulated FMM003 device, which exposes
      separate "Latitude coordinate value"/"Longitude coordinate value" sensors) publish them as
      two independent `sensor` entities instead, which `_standort()` had no way to consume. Added
      `STANDORT_LAT_SENSOR`/`STANDORT_LON_SENSOR` (new optional fields in `einstellungen.py` +
      `entitaeten.py`'s Setup-menu catalog, group "standort") as a second path: `_standort()` now
      tries `STANDORT_TRACKER` first (unchanged behavior when it works), and falls back to reading
      the two plain sensors' states directly when the tracker is unset or has no coordinates.
      (4) **App can show only "Lädt …" until a tab is clicked** — known, already-mitigated race
      (see the `nachladeAnstossen` header comment, `audi-dashboard-app.js`): on a slow/cold backend
      start, `render()` no-ops until `DATEN_GELADEN` flips true, and the previously observed
      manual fix was a HA panel remount. Root cause of *why* the click helps was not fully
      reproducible in `audi_ha_test` (this is a genuine uncertainty, flagged rather than guessed
      around) — but as a safe, low-risk hardening, `go()` (the tab/menu click handler) now calls
      `datenLaden(false)` proactively if `DATEN_GELADEN` is still false, so any click that happens
      to occur after data has actually become available is guaranteed to pick it up immediately
      instead of depending on timing.
      (5) **Fuel-receipt parser only understood Shell's exact layout** — user asked for a generic
      algorithm for other/unknown fuel stations: find the address, find the total ("Gesamt"/
      "Absolut"), find the liters ("Menge"/"Amount"), divide to get the paid price/liter, compare
      against a printed "Preis/Liter" — a mismatch means a discount, found on the receipt as a
      minus-marked amount. Implemented exactly this as `_parsen_generisch()` in
      `homeassistant/data/shell_beleg_parser.py`, wired as a fallback in `main()` (Shell-specific
      `_parsen()` tried first, unchanged — verified the existing 10-receipt regression suite still
      passes byte-for-byte; `_parsen_generisch()` verified against a synthetic non-Shell receipt
      with a known discount, all fields and the discount math correct). No separate "SmartDeal"
      flag was needed — the frontend already treats any populated `discount` as SmartDeal-eligible
      (gated only by the user's own "SmartDeal aktiv" switch, not by station name), so a generically
      parsed Shell receipt behaves identically to one parsed via the strict path. Receipt-key
      generation uses `hashlib.sha1` (not the built-in `hash()`, which is randomly salted per
      process and would have broken the §7.7 duplicate-detection dedup across the parser's
      per-receipt subprocess invocations).
      All five deployed and verified live in `audi_ha_test` (version `1786752000`): Sicherheit-
      Liste and Setup-Katalog confirmed to no longer offer Türschloss/Haubenschloss; the new
      Breitengrad/Längengrad Setup fields confirmed present and correctly auto-suggesting the
      container's real split lat/lon sensors; "Doppelt zugeordnet" confirmed rendering on an opaque
      card by deliberately triggering it (this container's limited entity set causes a real
      duplicate auto-suggestion); pyscript reload clean, no new console errors beyond the
      pre-existing placeholder-image 404s. Synced to `installationspaket/`.
- [x] Follow-up round the same day (2026-08-16), three more items:
      (1) **Removed `STANDORT_TRACKER` entirely** — user confirmed the combined device_tracker
      field is no longer needed (flespi only ever provides the split lat/lon sensors added earlier
      that day). Deleted the field from `einstellungen.py` and `entitaeten.py`'s Setup catalog, and
      simplified `_standort()` in `frontend_veroeffentlichung.py` down to the single split-sensor
      path (no more two-branch fallback). `overrides_schreiben()`'s existing "only known keys
      survive" behavior (see its docstring) meant no explicit migration was needed for stale
      `STANDORT_TRACKER` entries already sitting in `data/entitaeten.json` — confirmed live: this
      container's own leftover override file still had `STANDORT_TRACKER` from earlier testing
      (plus stale `TUERSCHLOSS_SENSOREN`/`HAUBENSCHLOSS_SENSOR` from before finding (1) of the
      previous entry), all silently dropped on the next Setup save, which is exactly why the GPS
      tile was still showing "Kein GPS-Signal" after the user's own remap attempt earlier — that
      attempt happened to save while the old combined field was still in the catalog, so nothing
      ever actually persisted `STANDORT_LAT_SENSOR`/`STANDORT_LON_SENSOR`. Redid the Setup save
      live (mapped both to `sensor.testcar_b9_fmm003_testintegratoin_lat/longitude_coordinate_value`)
      and confirmed the Übersicht/fullscreen Standort map switched from the "no signal" placeholder
      to a real rendered map at the correct coordinates (South Tyrol terrain, "Burgleralm" label
      visible).
      (2) **Fuel-receipt "silently does nothing", root cause found** — reproduced the exact
      real upload flow (button click → real `<input type=file>` → synthetic `File`/`DataTransfer`
      dispatch → `FileReader` → `hass.callService`) via browser instrumentation: the happy path
      works completely (backend parses, publishes `pyscript.audi_dashboard_beleg_ergebnis`, frontend
      fills the form) — `belegverarbeitung.py`'s existing `try/except` around `_parser_aufrufen()`
      already publishes a visible `fehler` for parser failures specifically. The actual gap:
      `base64.b64decode()` and `_pdf_speichern()` in `audi_dashboard_beleg_hochladen()` were
      *outside* that try block — any failure there (corrupted upload, disk full, ...) raised an
      unhandled exception with zero UI feedback, since `serviceRufen()`'s generic `.catch()` only
      does `console.error()`. Widened the try block to cover all three steps. Also stopped using
      `serviceRufen()` for this one call site specifically (`audi-dashboard-app.js`, the
      `eingabe.onchange` handler) — it now calls `HASS.callService()` directly with its own
      `.catch()` that sets `belegFehler` and re-renders, so a rejected service call (network,
      timeout — anything that bypasses the backend's own state-publish) is visible too, not just
      logged. Verified against the real 10-receipt Shell suite (still passing) both before and
      after this change.
      (3) **Vehicle map marker replaced with a two-tone pin+car icon** — added `CI.pinCar` (a
      balloon/pin path + white badge circle + a simplified car-front glyph built from rects, not
      circles, after an isolated-DOM-injection test round showed a circles-for-headlights version
      read as a face/animal rather than a car) to `audi-dashboard-app.js`, replacing the single-color
      `poiCar` teardrop previously used by `fahrzeugMarkerSVG()`. Updated both marker call sites'
      `iconAnchor` to the new pin's actual tip position and simplified `.fahrzeug-pin` in
      `audi-dashboard.css` (no more `color:var(--red)`/white-halo-filter hack, since the new icon
      carries its own fixed colors). Verified the icon markup renders correctly via an isolated
      DOM-injection test (bypassing Leaflet); could **not** get a final on-map screenshot in
      `audi_ha_test` — the Standort map tiles themselves only ever partially load in this container
      (reproduced on a fully fresh reload, unrelated to this change), consistent with a previously
      documented network limitation of this specific sandbox (see the Standort-Kachel entry above:
      reverse-geocoding via Nominatim had the same kind of container-specific network issue).
      Structurally verified only; needs a look on a real device/network to confirm final visual
      placement.
      All three deployed to `audi_ha_test` (version `1786758000`) and synced to `installationspaket/`.
- [x] Third round the same day (2026-08-16) — **the "fragmented Leaflet rendering" defect is solved
      at its root**, plus four smaller items:
      (1) **Leaflet CSS never reached the shadow root.** `leafletLaden()` appended
      `leaflet.min.css` to `document.head`, but the panel renders inside `this.shadowRoot` — and
      document stylesheets do not cross a shadow boundary. Inside the panel, `.leaflet-tile
      {position:absolute}` and friends therefore never applied: the tiles laid themselves out as
      ordinary in-flow `<img>` elements (two per row, ~1040 px of stacked height inside a 190 px
      box), so only a thin strip of map was ever visible, and the marker pane ended up ~1009 px
      below the visible area — which is exactly why the new vehicle pin appeared "missing". This
      also explains the earlier misdiagnosis as a container/network limitation: every tile request
      succeeded (`complete:true`, `naturalWidth:512`), the geometry was the problem. Fixed by
      injecting the stylesheet into `ROOT` and awaiting it before the map is built; the script tag
      stays in `document.head` (it must, `window.L` is global). The CSS injection is deliberately
      re-checked on every `leafletLaden()` call rather than guarded by the `window.L` check, because
      a HA panel remount produces a fresh shadow root while `window.L` is already set. Verified live:
      both the Übersicht preview map and the fullscreen Standort map now fill their containers and
      show the pin at the vehicle position.
      (2) **Maps are now always light.** `TILES[theme]` switched to CARTO `dark_all` in Nacht mode;
      replaced by a single themeless `TILE_URL` (`light_all`), matching how Google Maps & co. keep
      the standard road map light regardless of app theme.
      (3) **Vehicle marker now uses the real CI `poi-car` icons** (`poi-car-l.svg` / `poi-car-s.svg`
      from the delivered icon set) instead of the hand-built two-tone pin from the previous round:
      `CI.poiCarL` (48-grid) at ≥34 px, the existing `CI.poiCar` (24-grid) below, anchors recomputed
      per variant. Since these are single-color outline forms in `currentColor`, `.fahrzeug-pin`
      regained a stacked white drop-shadow halo so the outline stays readable on map tiles.
      (4) **Red partial frame on Fahrten/Tanken list rows** (open since the 2026-08-13 design audit,
      finding 3) — reproduced and confirmed by recoloring the delete button live. Row heights are
      fractional (66.28 px), so `.swipe-content`'s edges miss the device-pixel grid and the red
      `.swipe-delete` behind it bled through as a hairline around every row. Fixed by only painting
      the delete button while a swipe is actually happening: `.swipe-delete{visibility:hidden}` plus
      a `wischt` class set on the first movement of the gesture (and the existing `swiped` class for
      the open row). Verified the full gesture still works — button appears at the first pixel of
      drag and the row settles open at −84 px.
      (5) **Select fields now show the grey background box** (design audit finding 1) — replaced
      `.feld select{color:var(--ios-tint)}` with `background:var(--ios-fill);padding:8px 10px`,
      matching `.mitEinheit input` ("Pause bis [15] Minuten"), as the user asked. Verified on
      Modell / Ölwechsel-Intervall / Bildposition; free text fields stay plain, so the box now
      genuinely marks "there is a choice here".
      (6) **Generic receipt parser: total and station name** — a real Austrian non-Shell receipt
      returned the right litres but 6,00 € and no station. Two causes, both fixed: the free-running
      total pattern matched the *column heading* `SUMME-EUR` and read the next line's article number
      as the amount, and bon printers letter-space headings (`G E S A M T  BETRAG EUR: 92,60`), which
      the keyword never matched. Total detection is now line-based — keyword checked against the
      whitespace-stripped line, amount must be a real money value (`_GELD`, decimals required), tax
      lines (`MWST`/`UST`/`VAT`/`STEUER`/`NETTO`) excluded, largest remaining candidate wins. The
      address heuristic no longer demands three fixed lines with a 5-digit postcode (AT uses 4
      digits, and this receipt carries `SHELL TANKSTELLE, 6450 SÖLDEN` on one line): it finds the
      first postcode line in the header and takes the name from the line above. `Preis/L` without a
      spelled-out "Liter" now matches too. Result on that receipt: 42,13 l · 92,60 € · 2,198 €/l ·
      no discount · `AUTO B. FRISCHMANN GMBH`. The 10-receipt Shell regression suite still passes.
      All deployed to `audi_ha_test` (version `1786765000`) and synced to `installationspaket/`.
- [x] Fourth round the same day (2026-08-16):
      (1) **The "grey box around the icons" report is resolved — it was never a defect.** After five
      fruitless reproduction attempts across two sessions, the user clarified what they actually
      meant: the grey box seen on "10.000 km", "1 Jahr", "15" is the *desired* pattern, and **every
      value the user can change should carry it** to signal editability. `.feld input`/`.feld select`
      in `audi-dashboard-ios.css` now all get `background:var(--ios-fill);padding:8px 10px`;
      inputs with their own visual language (`checkbox`/`radio`/`range`/`file`) are excluded and keep
      the transparent base rule, so the three switches stay untouched. The separate
      `.mitEinheit input` rule became redundant and was removed (its width/alignment still come from
      `audi-dashboard.css`). Verified live: FIN, Kennzeichen, Erstzulassung, Ausführung, Modell,
      both Ölwechsel selects, Ansicht, Pause bis and Autom. Backup all boxed; the three checkboxes
      not. **Do not re-open this as a bug.**
      (2) **Station display name is now "Marke, Straße, Ort"** (e.g. `Shell, Pascalstr. 8,
      Ingolstadt`) instead of the bare operator name (`A. Zrenner GmbH`) — the brand and place say
      more when scrolling the Tankvorgänge list. New `_marke()`/`_ist_strasse()`/`_tankstelle()`
      helpers in `shell_beleg_parser.py`, shared by both parser paths so Shell and non-Shell
      receipts format identically. The brand is matched only against the first 8 lines (the receipt
      header) — searching the whole text would let "Total" hit a totals line. Missing parts are
      dropped rather than left as empty comma slots (the Austrian receipt prints no street:
      `Shell, SÖLDEN`), and without a recognizable brand the operator name takes the brand's place.
      `station_address` keeps the full street + postcode + city as before. The regression suite's
      `test_bekannte_stationen` expectations were updated to the new format (intended change, not a
      break); all 8 tests / 164 subtests pass.
      (3) **`installationspaket/` is versioned from now on** (user request) — removed from
      `homeassistant/.gitignore`. It carries no real vehicle data, only
      `data/fahrzeugprofil.example.json` with empty FIN/Kennzeichen placeholders; verified before
      un-ignoring. **Keep it in sync whenever `pyscript/` or `www/` changes** — that was already the
      working rule, but it is now visible in the repo when it is forgotten.
      Deployed to `audi_ha_test` (version `1786766000`).
- [x] Fifth round the same day (2026-08-16) — one-click installer and the Einzelbeleg map:
      (1) **`installationspaket/Installieren.cmd` + `install.ps1`** automate steps 1–3 of
      `ANLEITUNG.md`. The `.cmd` wrapper exists because double-clicking a `.ps1` opens it in an
      editor and the execution policy blocks it; the bypass is per-call, the machine policy is not
      touched. Safe to re-run: never overwrites `fahrzeugprofil.json`, `fahrten.jsonl`,
      `tankvorgaenge.jsonl`, `entitaeten.json`, `ha_token.txt`; backs `configuration.yaml` up to
      `.bak` and replaces its own marker-delimited block instead of appending twice; refuses to
      touch the file at all when `pyscript:`/`panel_custom:` are already there from another source
      (duplicate top-level keys are invalid YAML — HA would not start); aborts before writing if
      the target has no `configuration.yaml`. `-Pruefen` is a dry run. **`install.ps1` is stored
      UTF-8 *with* BOM on purpose** — PowerShell 5.1 reads scripts as ANSI otherwise and mangles
      every umlaut; the files it *writes* stay BOM-less (YAML). Verified against a fake config
      tree: fresh install, re-run idempotency, data preservation, foreign-key refusal,
      wrong-directory abort, and the merged YAML parses.
      (2) **Tankstellenposition im Einzelbeleg.** The red `circleMarker` is now the CI `poi` pin
      (`CI.poiL`/`CI.poiS` from `poi-l.svg`/`poi-s.svg`, via `tankstellenMarkerSVG()`) — deliberately
      the plain pin, not `poi-car`, so vehicle and station stay distinguishable.
      **Found while testing: nothing in the backend ever writes `station_lat`/`station_lon`**, so
      this marker had never actually been reachable. Fixed by geocoding the receipt address in the
      frontend (`adresseAufloesen()`, Nominatim `/search` — the forward counterpart to the existing
      `standortAdresseAufloesen()`), with results cached in `localStorage` (`audi_dashboard_geocache`)
      including negative results, since a station address does not move and Nominatim's terms ask
      for caching. Confirmed live: `Shell, SÖLDEN` → 46.9756/11.0111.
      (3) **Zentrieren-Knopf** in the Einzelbeleg map card (`mapBoxMitZentrieren()`, `CI.gps` —
      byte-identical to the delivered `gps-s.svg`), disabled until the coordinates arrive. `.mapbox`
      gained `position:relative` so the control anchors to the card. Only there, per explicit
      request — the Standort fullscreen already has its own centre controls.
      Deployed to `audi_ha_test` (version `1786769000`).
      Note for testing map behaviour in this app: `initMap()` runs on **every** `render()`, and the
      app re-renders on each backend push (~30 s), rebuilding the map and re-centring it. Panning
      the map from the console and checking later therefore proves nothing — spy on
      `L.Map.prototype.flyTo` instead.
- [x] Sixth round the same day (2026-08-16) — a large batch of user-reported UI polish items, plus
      the map pins' real visibility root cause:
      (1) **Map pins were genuinely near-invisible, not just a styling nit.** The user reported the
      vehicle's map pin looked "transparent"; reproduced empirically (not by eye) by rasterizing the
      live marker's SVG to a canvas and sampling alpha — only ~13% of the icon's own bounding box had
      any ink, and the exact center pixel was fully transparent. Root cause: `CI.poiCarL`/`poiCar`
      (and `poiL`/`poiS`, used for the Einzelbeleg station pin) are pure **contour** forms — the
      balloon outline is drawn as two nested, oppositely-wound paths that cancel to a 1px ring under
      the default nonzero fill rule, plus thin interior detail lines; there is no filled area at all.
      A first fix (solid navy circle behind just the icon's round "head", via `::before`) was
      explicitly rejected by the user ("I want the whole icon solid, not just the car" / "make the
      needle opaque") — correct, since it only covered ~36% of the icon and left the pointed tip as a
      bare outline. Real fix: each icon's `d` attribute is multiple sub-paths (split at top-level
      M/m); the **first** sub-path alone is already the complete, correct outer balloon silhouette
      (verified by rasterizing it in isolation — solid center pixel, 36-41% fill ratio, matching a
      normal teardrop-in-square ratio). Extracted that first sub-path verbatim (no redrawing) into
      four new `CI.poiCarLSil`/`poiCarSil`/`poiLSil`/`poiSSil` constants, fixed-color-filled (not
      `currentColor`), and `fahrzeugMarkerSVG()`/`tankstellenMarkerSVG()` now render it as the bottom
      layer behind the original detailed icon; `.fahrzeug-pin`'s color switched from dark navy to
      white so the original outline/detail lines read against the new solid fill, exactly like the
      white-glyph-on-solid-teardrop convention of Google/Apple Maps pins. Applies uniformly to both
      marker types now (station pin included, per explicit user request), so the earlier per-variant
      `.auto` CSS modifier and its circle-position math were removed again as dead weight.
      (2) **Battery voltage statistic cutoff lowered 13.2V → 12.8V** (`AGM_RUHE_MAX_V` in
      `audi-dashboard-app.js`) — user's point: anything above ~12.8V (full-charge resting voltage per
      the existing in-code research citation) is already alternator output, not the battery's own
      state, so admitting up to 13.2V let charging-tail values distort the charge-state stats.
      (3) **Battery chart Y-axis no longer "stretches."** `bvZeichnen()` recomputed `yMin`/`yMax` from
      whatever points were currently zoomed into (min 0.4V span + 15% pad) on every redraw, so the
      axis rescaled on every pan/zoom gesture. Now computed once from the full dataset in
      `vBatterieverlauf()` (`bvYDomain`) and reused unchanged across zoom/pan.
      (4) Removed two explanatory paragraphs from the Batteriespannung detail view ("Näherung auf
      Basis…" and "Ziehen zum Verschieben…") per explicit request.
      (5) **Tankfüllung now shows "X % / X l"** (`vAudi()`) instead of a literal-litre primary value
      with a redundant "100 % von 58 l" sub-line.
      (6) Removed the "· noch etwa X l im Tank" suffix from the Übersicht's "Reichweite" label
      (`vHome()`); the now-unused `liter` local was removed with it.
      (7) **Active/primary buttons are grey again, not red** — `.aktion.primaer` in
      `audi-dashboard-ios.css` filled with `--ios-tint` (red) since the 2026-08-13 iOS-overlay
      decision; explicit user request to drop that and match the base stylesheet's neutral
      `var(--fg)`/`var(--canvas)` fill. Destructive actions (`.aktion.loeschen`) intentionally stay
      red — Apple's own HIG reserves red specifically for destructive actions, so this one use is
      correct per the guideline, not just left over.
      (8) **`<select>` fields now carry an Apple-HIG pull-down chevron.** They already had
      `appearance:none` (native arrow removed) but no replacement indicator at all — a real gap, not
      cosmetic. Added a CSS `background-image` chevron (light/dark variants) in both stylesheets;
      base `audi-dashboard.css` kept independently correct (own light/dark variant via
      `:host([data-theme=tag])`) per this repo's "purely additive overlay" convention for the ios
      file.
      (9) **Apple HIG research round.** Read Color/Typography/Layout/Buttons/Materials directly off
      developer.apple.com (the WebFetch tool returned only page titles for this JS-rendered site: the
      in-app Browser tool's `get_page_text` worked and was used instead) and cross-checked against
      actual measurements in this codebase, then asked the user via `AskUserQuestion` which findings
      to apply. Accepted: text below the 11pt iOS legibility minimum (battery-chart axis labels were
      8px, the header's "Sync" line was 10.5px) raised to 11px; the new compact "Montiert" pill's
      *invisible* hit area (not its visual size) expanded to the 44×44pt minimum via a `::before`
      with negative insets, same technique applied to future compact controls. Declined, deliberately:
      switching the app's few weight-300 ("Light") text spots to 400 — Audi Type only ships 300/400,
      and the user chose to keep Audi CI's own weight over HIG's general "avoid Light" guidance;
      making popups/sheets translucent to match the Materials guidance — the app already restricts
      translucency to the nav layer only (tab bar), which *is* the HIG-correct pattern, and popups
      were deliberately made opaque on 2026-08-16 earlier the same day to fix a real see-through bug,
      so translucency there would be a regression, not an improvement.
      (10) Removed "Aus dem Fahrzeug gelesen" and the Einrichten intro paragraph ("Fahrgestellnummer,
      Kennzeichen, Erstzulassung…") from `vEinst()`, both per explicit request.
      (11) **Fuel bar color now matches the headline** (`var(--fg)`) instead of a red/yellow/green
      status gradient in `vHome()`; the `balkenFarbe` local was removed with it.
      (12) **Selected menu icon is white (headline color), not red**, in the desktop side-nav
      (`.tab.on` inside the `@container (min-width:860px)` block in `audi-dashboard-ios.css`) — the
      mobile tab bar already used `var(--fg)` there, this brought the desktop variant in line.
      (13) **Reifenfoto (Sommerrad/Winterrad) now crops from the right edge**, not the center —
      `.radbild` gained `object-position: right center`; the photo is typically wider than the 96px
      square slot and the wheel itself tends to sit toward the right of the source photo.
      (14) **"Montiert" button rebuilt as a compact top-right pill** (new `.montiert` class,
      replacing the old full-width `.seg`-wrapped button) per explicit layout instructions: top edge
      aligned with the wheel photo's top (`top:var(--sp-5)`, same offset the photo already has from
      the tile's own padding), right inset equal to the tile's standard left content inset (both
      `var(--sp-5)`) so left/right margins match. Markup reordered so the button renders before
      `.radkopf` (position is absolute either way, but keeps DOM order sensible).
      (15) **Tab-bar active highlight now covers icon *and* label when the label is visible**, not
      just a tight pill behind the icon — `.tabbar:not(.ohne) .tab.on` gets the highlight background
      directly (whole button), with the inner `.tabpille` background suppressed in that case; the
      icon-only mode (`.tabbar.ohne`) keeps the original tight icon-pill unchanged.
      Deployed to `audi_ha_test` (version `1786901000`); pin fix visually confirmed live (solid
      teardrop pin with white car glyph, clearly over the map's "Obergurgl" label text) and the
      tab-bar highlight confirmed covering both icon and "Übersicht" label.
- [x] Seventh round the same day (2026-08-16) — more user-reported layout/interaction bugs:
      (1) **Wheel photo (Sommerrad/Winterrad) was never actually a square, despite the fifth round's
      crop-direction fix.** Root cause: `bildMitPlatzhalter()` puts the `radbild` class on *both* the
      wrapping `.bildbox` div and the `<img>`; the old `.radbild{width:96px;height:96px;...}` rule and
      `.bildbox{width:100%;aspect-ratio:4/3;...}` have equal specificity (one class each), and
      `.bildbox` is declared later in the file, so it silently won — the box stayed a full-width 4:3
      rectangle the whole time, `object-position:right center` just changed which slice of that wide
      box showed. Fixed with a `.bildbox.radbild{width:96px;height:96px;aspect-ratio:1/1}` compound
      selector (two classes, unambiguously higher specificity than plain `.bildbox`); `.radbild` alone
      now only carries `object-position`. Verified live: `.bildbox.radbild` now measures exactly
      96×96px.
      (2) **"Montiert" moved from top-right to top-left**, per explicit request — this was the visible
      symptom of (1): the pill was anchored top-right assuming it overlaid the (accidentally) full-
      width photo; once the photo is a real 96px square on the left, top-left is where it actually
      needs to sit to stay over the picture.
      (3) **Native `<select>` popups now get a `color-scheme` hint.** The closed `<select>` box itself
      was already themed correctly, but the *opened* native option list ignores app CSS and falls back
      to the browser/OS default palette unless `color-scheme` says otherwise — this is what made
      "Modell" and every other dropdown "flash" bright white against the dark app on open. Added
      `color-scheme: dark` under `:host([data-theme="nacht"])` and `color-scheme: light` under
      `:host([data-theme="tag"])` in `audi-dashboard-ios.css`.
      (4) **Fixed a real JS crash in the "Mein Audi" image-cycle click handler.** `bildWeiter()` (fires
      on tapping the hero photo) tried `box.querySelector(".platzhalter-datei").textContent = ...` —
      that class was never emitted by `bildMitPlatzhalter()` (the actual class is `.platzhalter-
      aktion`, and in gallery mode it deliberately shows a static "Foto hinzufügen" prompt, not a
      filename). `querySelector` returned `null`, the assignment threw, and the exception aborted the
      function *before* reaching the line that updates the `.dots` page indicator — so the photo
      itself advanced (that line ran first) but the dots never moved, reading as "doesn't work."
      Removed the dead line entirely; verified live that the `.dots` `on` class now correctly moves
      with each click and no exception is thrown.
      (5) **Merged the separate "Fahrzeugbilder" upload grid into "Bild der Übersicht"**, per explicit
      request: pick a view from the existing "Ansicht" dropdown, then tap the (now also click-to-
      upload) preview below it to upload/replace/delete that specific photo — reusing the same
      generic `data-bildklick`/`bildMenuOffen` popup plumbing already used by the wheel-photo tiles,
      not a new mechanism. `bildInfo()`'s existing winter-side-view special case (swaps in
      `seitenansicht-winter.webp` while winter tires are marked mounted) means the winter variant
      stays uploadable through the same control, gated by current tire season instead of a permanently
      visible separate slot. The now-unused `BILDER_UPLOAD_SLOTS` constant and the `.bildgrid`/
      `.bildslot`/`.bildslot-label`/`.carfix.mini` CSS were removed as dead code; a new
      `.bildmenu.ansicht` positions the ersetzen/löschen popup anchored to the tile's *bottom* edge
      (not a fixed top offset like the other two menu variants) since this preview's height varies
      with the tile's own content instead of being a fixed small thumbnail.
      (6) **Back arrow changed from red (`--ios-tint`) to the neutral headline color (`--fg`)** in
      `audi-dashboard-ios.css`, matching every other navigation-color decision made this project (red
      stays reserved for destructive actions per Apple's HIG).
      Initially investigated but could not reproduce (see below for the real fix, found later the same
      day): user reported that hiding tab labels ("Beschriftung in der Menüleiste" off) makes the
      *entire* tab bar disappear. First pass traced the CSS cascade and the commit that added the sixth
      round's icon+label highlight and found it correctly scoped to `:not(.ohne)`; live DOM/computed-
      style testing (toggle, click the label, navigate away/back, both container widths) checked
      `#tabbar`'s and each `.tab`'s own box — all stayed visible, sized, and classed correctly — so the
      bug looked unreproducible and was provisionally left alone.
      Deployed to `audi_ha_test`; wheel-photo squareness, Montiert position, back-arrow color, and the
      image-cycle dots all confirmed live via direct DOM/computed-style checks (no visual screenshot
      tool available this session — see running note below).
      Follow-up same day: user reported the `color-scheme` fix alone wasn't enough — dropdowns were
      "still very bright." Correct: a `<select>`'s *opened* option list is rendered by the browser/OS
      as a separate surface outside the page's render tree entirely — confirmed firsthand when a
      screenshot tool call actually hung for 30s trying to capture it, and even a real click-to-open
      attempt afterward produced no visible change in any screenshot. `color-scheme` only gets partial
      credit there; the reliable lever is that Chrome/Firefox/Edge *do* honor explicit
      `background-color`/`color` set directly on `<option>` elements for the popup rows. Added
      `.feld select option{background-color:var(--canvas);color:var(--fg)}` — deliberately `--canvas`,
      not `--tile-2` (translucent in the night theme, would let the browser's own light backdrop show
      through). Verified via `getComputedStyle` on the actual `<option>` nodes
      (`background-color: rgb(12,16,20)`, `color: rgb(255,255,255)` in night theme) since the popup
      itself is provably outside what any screenshot in this tool can capture — user should confirm
      visually on their own device.
      Also: "Montiert" and the wheel photo were requested swapped — photo now sits on the tile's right
      edge (`.bildbox.radbild{margin-left:auto}` inside its full-width wrapper), Montiert stays
      `left`-anchored, unchanged from the seventh round.
      **Found the real tab-bar bug right after, from the user's own screenshot** ("in this view i dont
      see any menu items" — a live repro of icon-only mode, this time actually screenshotted since the
      browser pane had recovered). The earlier live-testing pass checked `#tabbar` and `.tab`'s own
      boxes and both looked fine — the actual defect was one level deeper and only visible by measuring
      the icon itself: `.tabbar.ohne .tab span{display:none}` (in both stylesheets) was meant to hide
      only the trailing label `<span>${t.label}</span>`, but the icon is *also* wrapped in a `<span
      class="tabpille">` — a bare type selector `span` does not care about class, so the rule hid the
      icon's wrapper too. `getBoundingClientRect()` on the `<svg>` read `{w:0,h:0}` and
      `getComputedStyle(.tabpille).display` read `"none"` once actually checked — that was the missing
      measurement in the first pass. This is exactly the reported "whole menu bar disappears": in icon-
      only mode every tab lost both its label *and* its icon, leaving five empty buttons in an otherwise
      correctly-visible bar. Fixed with `.tabbar.ohne .tab span:not(.tabpille){display:none}` in both
      `audi-dashboard.css` and `audi-dashboard-ios.css` (the base file's copy was already shadowed by
      the ios one at matching specificity, but fixed too rather than left as a landmine). Verified live
      via screenshot: all 5 tab icons render correctly in icon-only mode, active tab still shows its
      filled pill.
- [x] Eighth round the same day (2026-08-16) — Reifen tile relabel + reflow, per explicit request:
      "Sommer"/"Winter" → **"Sommerräder"/"Winterräder"** (only the section headline text in
      `vReifen()`'s `satz()`; the singular "Sommerrad"/"Winterrad" photo label and the `data-satz`
      value compared against `CAR.reifen.aktiv` elsewhere were deliberately left untouched — the first
      is grammatically correct as-is for a single wheel photo, the second is an internal state key, not
      display text, and changing it would break the mount-toggle matching in the `[data-satz]` click
      handler). That headline is now an actual **headline** — new `.tile-headline` class (20px,
      `var(--fg)`, same typographic family as the page `.title` one step down) replacing the small,
      muted `.label` styling every other in-tile section title uses.
      Reflowed so the headline+km sit **above** "Montiert" instead of the wheel photo overlaying it:
      new `.radkopf-row` (flex, `justify-content:space-between`) puts the headline/km block and the
      wheel photo side by side at the tile's top, both top-aligned; `.montiert` dropped `position:
      absolute` (no longer needs to float over the photo now that the photo isn't in that space) and
      became a normal-flow pill with `margin-top` under the headline block — simpler than the previous
      overlay hack, same 44×44pt invisible tap target via `::before`. `.bildbox.radbild`'s `margin-
      left:auto` (added last round to right-align it under its old block-flow parent) was removed since
      `.radkopf-row`'s flex layout does that job now. Deployed to `audi_ha_test`, confirmed live via
      screenshot for both Sommerräder (mounted) and Winterräder (unmounted) tiles.
- [x] Ninth round the same day (2026-08-16) — "Mein Audi" image-cycle tap, still broken after the
      seventh round's `bildWeiter()` crash fix: reproduced with a genuine mouse click (not a JS
      `.click()` call) via screenshot and found the actual cause was one level upstream. This docker
      test container has zero uploaded car photos, so every gallery image is in `bild-fehlt` state,
      and `.bildbox.bild-fehlt img{display:none}` makes the `<img id="carimg">` itself un-hit-testable
      — a real tap lands on the `.platzhalter` div instead, which the old handler
      (`e.target.id === "carimg"`) never matched, so nothing happened. First fix wrapped the gallery
      image in a `data-bildgalerie` container that opened the upload picker when empty and advanced
      when a photo was loaded; the user then clarified uploads should stay exclusively in Einstellungen
      ("Bild der Übersicht") — Mein Audi should only ever cycle. Settled on: the wrapper stays (needed
      so taps land on something clickable regardless of `bild-fehlt` state) but always calls
      `bildWeiter()`, no upload branch. Verified live with a real click (screenshot → click → recheck
      `.dots`): the active dot correctly advances, no console errors beyond this environment's
      pre-existing service-worker/404 noise.
      Also this round: replaced the "Farbskala Ladezustand (AGM, Ruhespannung)" text-only label above
      the battery voltage color scale with a small battery icon + "Ruhespannung" (new `.bv-skala-kopf`
      flex row, same geometry as the header's `.sync` icon+text pattern). First attempt hand-drew a
      generic outline battery glyph since no "battery" icon exists in this repo's local CI subset
      (`design/assets/icons/`); the user then supplied the real `battery-12v-l.svg`/`battery-12v-s.svg`
      files from the actual Audi CI icon set (not something this session has fetch access to — asked
      the user rather than guessing a URL or fabricating an icon and calling it authentic). Added both
      as `CI.battery12vL`/`CI.battery12vS` (verbatim paths, `l` on the 48-grid, `s` on the 24-grid,
      matching the existing `poi-l`/`poi-s` size-variant convention); only the `s` variant is used here
      via `ciSVG()` since this is a small 15px inline label icon.
      Deployed to `audi_ha_test`, confirmed live via screenshot.
      Also this round (user then supplied the real `edit-l.svg`/`edit-s.svg` too): added a manual
      km-correction control to the Reifen tiles, per explicit request — a small pencil icon (Audi CI
      `edit-s`, new `CI.editS`/`CI.editL`) to the left of each Sommerräder/Winterräder km figure, "the
      counter shall add continuously to these km even if the user changes the km". The tricky part
      was persistence: `configCarZuProfil()` *deliberately* strips `km` before every profile write
      (see its own comment - km is "vom Backend fortgeschrieben", a stale browser copy must never
      clobber a since-elapsed automatic increment), so simply mutating `CAR.reifen[k].km` and calling
      `profilSpeichern()` like every other Reifen field does would silently drop the edit. Added a
      dedicated backend service instead, `audi_dashboard_reifen_km_setzen(satz, km)` in
      `reifenzaehler.py`, that only overwrites the stored counter itself and leaves
      `reifen.referenz_odo_km` (the last-processed odometer reference) untouched - the existing
      `kilometerstand_geaendert()` trigger always reads the counter fresh off disk before adding the
      next delta, so continuing to accumulate from the corrected value required no other backend
      change at all. New `kmOffen` state (which satz's correction field is open, mirroring the
      existing `nmOffen`/`bildMenuOffen` single-open-popup pattern) plus `data-kmauf`/
      `data-kmspeichern` handlers, styled like the existing `.montiert`/`.zahnrad` controls (44×44pt
      invisible tap target via `::before`). Verified end-to-end: edited Sommerräder from 150→4200 km,
      confirmed the pyscript service ran via the container's HA log, then did a full page reload
      (forces a real backend re-fetch, not just optimistic local UI state) and the corrected value
      was still there.
- [x] Tenth round the same day (2026-08-16) — a real full Apple HIG audit, not just the sixth
      round's 5-page pass: read Tab Bars, Sidebars, Sheets, Alerts, Action Sheets, Toolbars/
      Navigation Bars, Buttons, Pickers, Loading, Feedback, Privacy, and Undo/Redo directly off
      developer.apple.com and cross-checked each against the actual code (not just CSS) — most of
      the app already held up (5-tab bar, destructive-button placement, 44pt tap targets,
      `prefers-reduced-motion` support, confirm-sheet button ordering all already correct). Three
      real findings surfaced, all fixed and user-approved via `AskUserQuestion` before implementing:
      (1) **A second, larger silent-failure gap than the one already fixed for receipt upload.**
      `profilSpeichern()` — the shared save path for roughly 25 editable fields across the app
      (Anzugsmoment, service book, SmartDeal, tax, insurance, oil change, pause time, backup
      interval, start image, ...) — was `async` with no `try`/`catch` at any of its ~25 call sites,
      so a failed backend write was an unhandled promise rejection with zero user feedback; the
      popup had already closed and the UI already updated optimistically, so the change looked
      saved when it silently wasn't. Wrapped the one call inside `profilSpeichern()` itself in
      `try`/`catch`, reusing the existing `hinweis()` error-sheet pattern — one central fix instead
      of ~25 call-site patches. `serviceRufen()` (the separate wrapper used by km-correction, manual
      trip/fuel entry, tire-set switching, deletions, restart, ...) got the identical treatment.
      Verified live by monkey-patching `hass.callService` to reject inside the running app (via
      `host.hass.callService = () => Promise.reject(...)`), then triggering a real Anzugsmoment save
      through the actual UI: before the fix, the popup silently closed with a stale "Uncaught (in
      promise)" console entry and no user-visible sign anything went wrong; after the fix, a
      "Speichern fehlgeschlagen" sheet appears with the real error text. A follow-up full reload
      confirmed the failed test write never reached the backend (value reverted to the real stored
      120 Nm) — the fix only adds feedback, it doesn't change persistence behavior.
      (2) **"Lädt …" bootstrap screen got a progress indicator** (new `.lade-spinner` CSS, a plain
      rotating ring) instead of bare static text, per HIG Loading guidance to show something moving
      while content loads — notable here because this exact screen has a documented history of
      genuinely hanging, so a spinner also helps a user tell "still loading" apart from "stuck".
      Respects the existing global `prefers-reduced-motion` override (`*, *::before, *::after`
      block already forces `animation-duration:.01ms` app-wide) with no extra code.
      (3) **Anzugsmoment and km-correction popups now use `.aktion.primaer` on Speichern**, matching
      the Setup and SmartDeal popups, which already distinguished their primary action — these two
      were the only popups in the app stacking two visually-identical plain buttons.
      All three verified live in `audi_ha_test` (version `1786916894`): spinner CSS confirmed via
      `getComputedStyle` (16×16px, `border-radius:50%`, `animation-name:lade-spin`) since a genuine
      race-condition-timed screenshot proved too fast to catch reliably; Speichern/Abbrechen
      hierarchy confirmed via screenshot on both popups; error-sheet fix confirmed via the
      monkey-patch test above. Synced to `installationspaket/`.
- [x] Eleventh round (2026-08-17) — "why can't I pull to refresh on Übersicht", investigated as a
      real bug report rather than assumed working: live-tested the gesture by dispatching real
      `PointerEvent`s through the exact `pointerdown`/`pointermove`/`pointerup` handlers in
      `ereignisseVerdrahten()` (the `ptrY0`/`ptrDrag` logic) — every step fired correctly (drag
      threshold, "Loslassen zum Aktualisieren" label swap, spinner, `datenAktualisieren()` call,
      collapse), so the app's own JS/CSS was never the defect. Root cause instead: `main#view` (the
      actual scroll container) had no `overscroll-behavior` set at all, so on a **real** touchscreen
      the browser's own native overscroll/pull gesture can intercept or compete with the custom drag
      before it reaches the threshold — this can't reproduce via desktop testing or synthetic pointer
      events, only real touch hardware, which is exactly why the live test above passed while the
      user's real phone didn't work. Fixed with `overscroll-behavior-y: contain` on `main#view` plus
      `overscroll-behavior: none` on the outer `:host > div` wrapper, so a gesture leaving `main#view`
      never chains up into the underlying Home Assistant page either. Re-ran the same synthetic
      pointer-event test after deploying — gesture still fires identically end-to-end, confirmed via
      `getComputedStyle` that `overscroll-behavior-y` computes to `contain`; no visual regressions,
      no new console errors. Synced to `installationspaket/`. Not yet confirmed fixed on the user's
      actual device — the code-level fix is the standard remedy for this exact symptom class, but ask
      if it's still reproducing before assuming this is fully closed.
- [x] Beleg-Upload: Ziehen und Einfügen (2026-08-17) — "Beleg hochladen" opened the native file
      picker directly, which is the wrong affordance for both of the user's actual routes: at the
      computer the PDF should be draggable in, on the phone it arrives as a PDF copied out of a mail
      and needs pasting. Replaced the immediate picker with a small dialog (`belegPopup` state,
      `vBelegPopup()`, rendered into the existing `#overlay` alongside `sheetMarkup()`/
      `vSetupPopup()`): a dashed drop area plus "Aus Zwischenablage einfügen" and "Datei auswählen"
      (the old path, kept as the third route). The upload itself was extracted out of the click
      handler into `belegDateiVerarbeiten()` so all three sources share one code path — including the
      deliberate non-`serviceRufen()` error handling whose reasoning is unchanged (see its comment).
      Notes worth keeping: `dragover` **must** `preventDefault()` or the browser just opens the PDF
      itself and no `drop` ever fires; the drag/drop/paste listeners hang on `document`, not on the
      drop zone, because the zone lives in the shadow DOM and a drag crossing its edge would
      otherwise be lost — and because `render()` replaces the overlay's `innerHTML`, so per-element
      listeners wouldn't survive. Phone pasting uses `navigator.clipboard.read()` (iOS shows its own
      paste prompt) with the keyboard `paste` event as the desktop path. Non-PDFs are rejected with
      an in-dialog message instead of being uploaded. Verified live in `audi_ha_test` by
      instrumenting `hass.callService` and driving real `DragEvent`/`ClipboardEvent`/`KeyboardEvent`s:
      drop → zone highlights (`darueber`), dialog closes, service called with the right filename and
      base64 payload; paste → same; a dropped PNG → "Das war kein PDF." and no service call; Escape →
      closes. Synced to `installationspaket/`.
      **Nachbesserung am selben Tag, nach Fehlermeldung des Nutzers** ("copy paste from pc brings
      error no pdf but it is the latest bill") — zwei echte Fehler, beide bestätigt:
      (1) `navigator.clipboard.read()` (der "Aus Zwischenablage einfügen"-Knopf) gibt laut
      Spezifikation **nur Text, HTML und Bilder** heraus. Eine im Explorer/Finder kopierte PDF-Datei
      taucht dort grundsätzlich nicht auf - der Knopf konnte am Rechner also gar nicht funktionieren
      und behauptete dann fälschlich, es liege kein PDF vor. Der Weg, der dort funktioniert, ist
      Strg+V: das `paste`-Ereignis trägt die Datei in `clipboardData.files`. Die Meldung nennt jetzt
      genau das, statt die Datei zu beschuldigen; die Ablagefläche sagt es von vornherein
      ("oder mit Strg+V einfügen", per `tastenkuerzelEinfuegen()` auf Mac Cmd+V).
      (2) Die Erkennung hing allein am MIME-Typ (`type === "application/pdf"`) - eine aus dem
      Explorer kopierte oder gezogene Datei kommt je nach Browser und Quelle aber mit **leerem**
      `type` an und wurde dadurch abgelehnt. Neue Hilfsfunktion `istPdf()` prüft zusätzlich die
      Dateiendung; live nachgestellt mit genau diesem Fall (`new File([...], "Tankbeleg_August.pdf",
      {})`, `type === ""`) - vorher abgelehnt, jetzt korrekt hochgeladen.
      (3) Auf Rückfrage des Nutzers ("why is it that way? computer has copy paste function") noch
      einen Schritt weiter: Der Einfügen-Knopf wird am Rechner jetzt **gar nicht mehr angeboten**
      (`zwischenablageKnopfSinnvoll()`, Prüfung auf `(pointer: coarse)`), weil er dort prinzipiell
      nicht funktionieren kann - ein Knopf, der zwangsläufig in eine Fehlermeldung läuft, ist
      schlechter als keiner. "Datei auswählen" wird dort zur primären Aktion; auf dem Telefon bleibt
      alles wie zuvor. Der Unterschied dahinter, kurz: Strg+V ist eine bewusste Nutzergeste an die
      Seite, deshalb bekommt die Seite den vollen Inhalt inklusive Dateien - `clipboard.read()` ist
      die Seite, die von sich aus die Zwischenablage ausliest, und das wird aus Datenschutzgründen
      stark eingeschränkt (sonst könnte jede Seite still mitlesen, was zuletzt kopiert wurde).
      Live geprüft: Desktop zeigt nur noch "Datei auswählen [primaer]"/"Abbrechen", Mobil (375px,
      `pointer: coarse`) weiterhin alle drei Knöpfe.
      (4) **Auch Strg+V half am Rechner nicht** - vom Nutzer getestet und zurückgemeldet ("ctrl+v
      doesnt work either, pc doesnt know where to paste"). Stimmt: Chrome stellt eine Einfüge-Geste
      nur zu, wenn es ein Einfüge-Ziel gibt (Eingabefeld/contenteditable), und das Fenster hat
      keines. Der Hinweis ist deshalb überall entfernt; am Rechner heißt es jetzt schlicht "PDF
      hierher ziehen / oder unten auswählen", am Telefon "Beleg einfügen / das aus der Mail
      kopierte PDF". Der `paste`-Listener bleibt als stille Zugabe für Browser, die das Ereignis
      doch zustellen, wird aber nirgends mehr beworben; `tastenkuerzelEinfuegen()` wurde damit
      verwaist und ist entfernt. **Am Rechner also bewusst nur zwei Wege: Ziehen und Dateiauswahl.**
- [x] Preis je Liter überall auf 2 Nachkommastellen (2026-08-17, Nutzerwunsch: "€/l for gas is
      common to be displayed as x,xx €/l") — alle sieben Stellen von `de(..., 3)` auf `de(..., 2)`
      umgestellt: Jahres- und Monatsschnitt in der Tankliste, Zeilenwert je Tankvorgang, "Preis je
      Liter" im Einzelbeleg, die Live-Vorschau in beiden Tankformularen (statisch und im
      `data-tankpreisfeld`-Handler) sowie die CSV-Spalte "€/l". Im CSV kein Informationsverlust: der
      Wert ist aus den ebenfalls exportierten Spalten Liter und Kosten jederzeit neu berechenbar.
      `companion-app` (Nachfolger-App) brauchte keine Änderung - deren `eur()` rundet ohnehin schon
      auf 2 Stellen. Live geprüft: keine `de(..., 3)`-Aufrufe mehr in der Datei, Liste und
      Einzelbeleg zeigen "2,07 €/l", "1,99 €/l" usw.
- [x] Twelfth round (2026-08-17) — six user-reported items, one of them a real dead-end in the
      bootstrap:
      (1) **`HECKKLAPPENSCHLOSS_SENSOR` removed for good**, the same treatment Türschloss/
      Haubenschloss got on 2026-08-16: gone from `einstellungen.py` (attribute deleted, not
      blanked), from `entitaeten.py`'s Setup catalog, and from `_sicherheitscheck()` in
      `frontend_veroeffentlichung.py`. `HECKKLAPPE_SENSOR` (the lid itself) stays. Verified live:
      "Geprüfte Punkte" no longer lists it, the Setup catalog no longer offers it.
      (2) **Bild-Platzhalter in Einstellungen war halb abgeschnitten** — reproduced by measurement,
      not by eye: the placeholder rendered 260px tall inside a 132px `overflow:hidden` box. Cause:
      `bildMitPlatzhalter()` puts the size class (`carfix`) on the box, the `<img>` **and** the
      placeholder `<div>`, and the desktop container query said `.carfix,.bildbox.carfix
      {height:260px}` — the bare selector therefore also hit the placeholder, whose `position:
      absolute;inset:0` is over-constrained by an explicit height (the height wins, `bottom:0` is
      ignored). Scoped both rules to `.bildbox.carfix`; image and placeholder fill the box via
      their own `100%` anyway. Verified: placeholder now measures exactly the box height.
      (3) **Inspektion bekommt eine "voraussichtlich am …"-Zeile** wie der Ölwechsel, aber auf
      Wunsch nur mit dem Datum. `oelwechselPrognose()` wurde dafür auf einen gemeinsamen Kern
      `servicePrognose(stichwort, km, monate)` zurückgeführt; `inspektionPrognose()` nutzt
      dieselben 30.000 km / 24 Monate wie `intervalle()`/`termine()`. Angezeigt in der
      Service-Kachel auf "Mein Audi" (auch ohne Fahrzeugmeldung, anders als beim Ölwechsel - dort
      nennt `terminText()` den generischen Termin schon "voraussichtlich", die Zahl stünde sonst
      doppelt) und auf der Service-Seite. Live geprüft: beide Stellen zeigen "voraussichtlich am
      20.10.2027", Ölwechsel unverändert.
      (4)+(5) **"Neue Fahrt" hat jetzt alle Felder der Einzelfahrt, und die Einzelfahrt lässt sich
      bearbeiten.** Neues gemeinsames `fahrtFelder()`/`fahrtFormularWerte()` (Datum, Startzeit,
      Dauer, Art, Start-/Zielort, Start-/Endkilometer, Distanz) nach demselben Muster wie
      `tankFelder()`/`tankFormularWerte()`, benutzt vom Erfassungsformular und vom neuen
      "Werte bearbeiten" auf der Einzelfahrt-Seite (`fahrtBearbeitenOffen`, Vorbild `vFill()`).
      Backend: `audi_dashboard_fahrt_manuell_anlegen` um dieselben Felder erweitert, neuer Service
      `audi_dashboard_fahrt_aktualisieren`. Zwei Punkte, die dabei zählen: von Hand gesetzte Felder
      landen in `edited_fields`, damit das Kilometerstand-Screening (§7.2) sie nicht später
      überschreibt - und weil `_datensatz_aktualisieren()` genau diese Liste schützt, hätte ein
      zweites Bearbeiten sonst nichts mehr bewirkt; dafür gibt es jetzt `profil.fahrt_bearbeiten()`
      (`schutz=False`), der Schutz gilt gegen die Automatik, nicht gegen den Menschen. Fehlt die
      Distanz, ergibt sie sich aus den beiden Kilometerständen (`km_quelle: "manuell"`, Status
      "vollständig"). Gleich mitgezogen: die Art-Pille auf der Einzelfahrt schaltete bisher nur
      lokal um und war nach dem nächsten Backend-Push wieder weg - sie ruft jetzt denselben
      Service. **Merkposten pyscript:** Generator-Ausdrücke gibt es dort nicht
      (`NotImplementedError: not implemented ast ast_generatorexp`, an `next((f for f in …), None)`
      real gesehen) - Listen-Abstraktionen dagegen schon. Live end-to-end geprüft: anlegen,
      bearbeiten, erneut bearbeiten, jeweils in `fahrten.jsonl` nachgesehen und nach Reload wieder
      im Formular.
      (6) **Titelzeile stand auf dem Handy 34px rechts von den Kacheln.** Derselbe Befund wie im
      Design-Audit 2026-08-13, dort aber nur in der Desktop-Container-Query behoben: `.back` ist
      auf Wurzelseiten unsichtbar (`opacity:0`), belegt aber Breite plus Gap. Die Regel
      `.back:not(.on){width:0;…}` gilt jetzt auf allen Breiten und hebt per `margin-right:-4px`
      auch den Gap auf; `.topbar` bekam links 16px statt 20px, passend zu `--seitenrand` von
      `main#view`. Gemessen: Titel und Kachel beginnen auf dem Handy beide bei 16px, auf dem
      Desktop beide bei 564px (dort blieben vorher 4px Rest). Detailseiten (`.back.on`) unberührt.
- [x] Ladebildschirm: der Takt darf nicht abreißen (2026-08-17) — Nutzerbericht "nach dem Neuladen
      dreht sich nur 'Lädt …', erst ein Klick auf einen Menüpunkt zeigt die App". Beim Lesen des
      Bootstraps fanden sich zwei echte Sackgassen, die genau dieses Verhalten erklären:
      `nachladeAnstossen()` stieg bei `!HASS || !HASS.callWS` **ohne** neuen Timer aus, und
      `datenLaden()` bei `!HASS` ebenso - danach lief nichts mehr von selbst, und der einzige
      Ausweg war ein zufälliger hass-Push oder ein Menüklick (`go()` ruft `datenLaden()` mit auf,
      genau die Abmilderung vom 2026-08-16). Beide planen jetzt die nächste Runde, statt
      auszusteigen; `disconnectedCallback()` stoppt den Timer dafür sauber, `connectedCallback()`
      stößt ihn wieder an. **Nicht reproduziert:** weder ein einfacher Reload noch ein
      Container-Neustart mit sofortigem Reload haben den Hänger in `audi_ha_test` gezeigt (die App
      war jedes Mal binnen ~2s da) - die Sackgassen sind im Code belegt, dass sie *die* Ursache
      beim Nutzer waren, ist damit aber nicht bewiesen. Tritt es erneut auf: Browser-Konsole
      erfragen, bevor weiter geraten wird.
- [x] Thirteenth round (2026-08-17, same day) — five more user-reported items on the trip form and
      one theme bug found while verifying it:
      (1) **"Dauer" replaced by "Ankunftszeit"; duration is no longer editable.** `fahrtFelder()`
      now has Startzeit + Ankunftszeit inputs and a plain (non-input) duration display next to
      them, computed by `fahrtZeitraum()`/`dauerText()` and kept live via a new
      `data-fahrtzeitfeld` input handler (same "patch the one node, don't call render()" pattern
      the existing `data-tankpreisfeld` handler uses, for the same reason: a full re-render would
      steal focus from the field being typed in). An arrival time not later than the start time is
      treated as crossing midnight (`+1 Tag`), matching how `_fahrt_beenden()` already handles a
      real overnight trip. `fahrtFormularWerte()` derives `ts_start`/`ts_end` from the same
      calculation - the backend never sees a separate duration field, so it can't disagree with
      the times. Verified live: 09:00→10:45 shows "1 Std. 45 Min.", 23:30→00:15 shows "45 Min."
      (crossed midnight correctly), and editing an existing trip's arrival time on the Einzelfahrt
      page persisted the recalculated `duration_s` (7200s for a 10:00→12:00 edit, checked in
      `fahrten.jsonl`).
      (2) **Art values now display capitalized** ("Privat"/"Arbeitsweg") wherever shown to the user
      - the trip-edit `<select>`, the trip-list row, and the Einzelfahrt pill - via a new
      `artText()` helper; the stored value stays lowercase (`"privat"`/`"arbeitsweg"`), since it's
      compared in several places (the pill, the "Arbeitsweg" statistic, the CSV export) and already
      exists that way in every trip recorded so far. The CSV export itself was deliberately left
      showing the raw value - a data file, not UI copy.
      (3) **Arbeitsweg pill no longer red.** `.pill.work` (`color:var(--red)` / a translucent red
      fill in the iOS overlay) is gone from both stylesheets along with the now-unused `work` class
      on the button; the pill uses the same neutral styling regardless of Art, matching the request
      that Arbeitsweg should look like Privat, not draw attention to itself.
      (4) **"tippen zum Umschalten" hint removed** from under the Einzelfahrt pill, per explicit
      request - the pill is still clickable, just without the caption.
      (5) **Found while re-verifying the placeholder fix from round twelve: it broke daylight mode.**
      `.bildbox.bild-fehlt .platzhalter{background:var(--tile)}` (added round twelve) reads fine in
      Nacht theme, where `--tile` is a faint white overlay on a near-black canvas - but the iOS
      overlay's Tag-theme block redefines `--tile:#FFFFFF`, identical to the canvas/most tiles
      around it, so the placeholder box became invisible in daylight mode (measured live:
      `background-color: rgb(255,255,255)` on both the placeholder and its surroundings). Switched
      to `--ios-fill` (`rgba(120,120,128,.16)` in Tag theme, `.24` in Nacht) - the same token
      `.feld select`/`.aktion` already use to mark "this is a surface", visibly grey in both themes.
      Verified live in Tag theme: `background-color: rgba(120,120,128,.16)`, clearly a distinct box
      against the white canvas (see screenshot taken during verification).
      All five deployed to `audi_ha_test` (version `1787009000`) and synced to `installationspaket/`;
      console showed only the pre-existing service-worker/404 noise from the missing vehicle photos,
      nothing new.
- [x] Service-Seite: Prognose-Fussnote bricht nicht mehr um (2026-08-17) — Nutzermeldung, das Datum
      der Ölwechsel-Prognose stehe in einer eigenen Zeile. Nachgemessen statt geschätzt: das
      `<small>` in der rechten Spalte war 29px hoch bei 217px Breite (zwei Zeilen), die
      Inspektions-Fussnote darunter mit kürzerem Text 14px (eine). Ursache ist nicht die Textlänge
      allein, sondern der Platz: `.row` ist ein Flex mit `dt`/`dd`, und `dd` bekommt nur den Rest
      neben dem Titel - für "9.029 km, voraussichtlich am 14.04.2027" bei 12,5px reicht das nicht.
      Statt an Schriftgröße oder Text zu drehen, wandert die Fussnote jetzt als eigenes Element
      (`.row-fussnote`) neben `dt`/`dd` und umbricht per `flex:1 0 100%` auf eine eigene Zeile über
      die **volle** Kachelbreite - dieselbe Stelle, an der sie vorher schon optisch stand, nur ohne
      die künstliche Breitenbegrenzung. Das `flex-wrap:wrap` ist bewusst mit `:has(.row-fussnote)`
      auf genau diese Zeilen eingegrenzt, damit keine der übrigen `.row`-Verwendungen (17 Zeilen
      allein auf "Mein Audi") ihr zweispaltiges Layout verliert. Die vorher verschachtelte
      fm-/nicht-fm-Verzweigung im Markup ist dabei zu einem `hatMeldung`-Zweig für den Wert plus
      einem separat berechneten `fussnote`-String zusammengefallen - die Anzeigelogik selbst
      (Ölwechsel-Fussnote nur bei Fahrzeugmeldung, Inspektion immer) ist unverändert. Live geprüft
      bei 375px und 1280px: beide Fussnoten 14px hoch (einzeilig), 307px bzw. 636px breit, linksbündig
      auf der Kachelkante; alle 17 `.row` auf "Mein Audi" weiterhin zweispaltig; keine neuen
      Konsolenfehler. Deployed als Version `1787003000`, synchron in `installationspaket/`.
- [x] **Gap found while auditing "what's needed for a real iPhone app" (2026-08-17), then closed
      the same day:** `companion-app/src/screens/` hadn't been touched since 2026-08-11 — every
      panel fix and feature from the six days since (HIG audit rounds, receipt upload, trip
      editing, Inspektion forecast, ...) existed only in `homeassistant/www/audi-dashboard-app.js`.
      User decision: port everything now, before any native build work (Android build itself
      deprioritized/uncertain per the user - Paul has an Apple Developer Program and will help with
      iOS signing). Audited both codebases field-by-field (not by diffing the panel's CSS/markup,
      which doesn't transfer to `@audi-dash/ui` components) and found six real, mechanically
      distinct gaps, all fixed:
      1. **`AGM_RUHE_MAX_V` was still 13.2** in `Batterie.tsx` - lowered to 12.8, matching the
         panel's 2026-08-16 fix (same rationale: above that is already alternator voltage).
      2. **Dead `wlan_name` field** - `profilAdapter.ts` still read/wrote it and `Einstellungen.tsx`
         showed it as an editable "WLAN des Fahrzeugs" field, even though WLAN-based trip detection
         was fully removed from the backend on 2026-08-12 (replaced by FMM003 ignition). Editing it
         had zero effect. Removed from the type, both directions of the adapter, the settings
         screen, and both test fixtures.
      3. **No Inspektion forecast** - `service.ts` only had `oelwechselPrognose()`. Refactored the
         shared math into a `servicePrognose()` core (kept `oelwechselPrognose()`'s public
         signature/behavior identical - all 9 existing tests still pass unchanged) and added
         `inspektionPrognose()` (fixed 30.000 km / 24 Monate, no customizable interval like the oil
         change has), wired into `Service.tsx` as a new "Eigene Prognose (Inspektion)" row. 3 new
         tests.
      4. **Arbeitsweg pill was red** - `Pill variant="work"` in `Fahrten.tsx`/`FahrtDetail.tsx`;
         `@audi-dash/ui`'s own source comment says the variant literally "recolors border+text to
         the accent red". Stopped passing the variant in both spots - same fix as the panel, no
         change needed to the shared design-system component itself.
      5. **Trip creation only took Beginn/Ende/Art** - the panel's fuller field set (Startort,
         Zielort, Start-/Endkilometer, Distanz) didn't exist here. New shared
         `src/screens/FahrtFelder.tsx` (type, defaults, backend-payload conversion, and the
         presentational field group) used by both the create form (`Fahrten.tsx`) and a **new**
         edit capability.
      6. **`FahrtDetail.tsx` was entirely read-only** - no edit, no Art toggle after creation. Added
         a "Bearbeiten" toggle reusing `FahrtFelder.tsx`, backed by a new `DataMetricApi
         .fahrtAktualisieren()` (mirrors the existing `tankvorgangAktualisieren()` pattern) calling
         the backend's `audi_dashboard_fahrt_aktualisieren` service (built this session on the panel
         side, so the backend contract already existed and needed no changes).
      **Explicitly investigated and found NOT to apply** (worth recording so it isn't re-attempted):
      price-per-litre rounding (companion-app's `eur()` already always renders 2 decimals - the
      3-decimal bug the panel had never existed here); pull-to-refresh `overscroll-behavior` (no
      native pull gesture implementation here to fix); the panel's drag/paste/file-picker receipt
      dialog (solves a **desktop browser clipboard** problem that doesn't exist in a Capacitor
      native app - the plain `<input type=file>` already in `TankDetail.tsx` gets iOS's native
      Files/Photos picker for free, which already covers "a PDF saved from Mail"); every
      Setup-menu/entity-mapping feature (HA-panel-only concept, the companion app consumes the
      backend's already-resolved published state and has no equivalent screen, nor should it);
      the many purely cosmetic HIG-audit CSS fixes (icon fills, chevrons, tab-bar highlight
      bugs, ...) - those target the panel's own hand-written markup/CSS, not
      `@audi-dash/ui`'s components, and don't transfer.
      Verified after every change: `npm run typecheck` (clean throughout), `npm run test` (95/95
      passing, up from 90 - added interaction tests for the new create-form fields and the
      edit-toggle pre-fill, plus 3 for `inspektionPrognose()`), `npm run build` (succeeds). Also
      corrected a stale README claim ("Capacitor-Hülle und sichere Ablage" both listed as open) -
      the secure-storage adapter is actually fully wired (`ablageNativ.ts`, activated from
      `main.tsx`) and only the native `ios/`/`android/` platform folders themselves (deliberately
      gitignored, regenerated via `npx cap add`) are the missing piece, and that step needs a Mac.
- [x] Übersicht: Kilometerstand-/Service-Kacheln neu gestaltet (2026-08-17) — Nutzer fragte, wie
      Apple HIG und die Audi-CI das lösen würden. Befund: die Ölwechsel-Kachel presste zwei
      verschiedene Größen in **ein** Zahlenfeld (`teile.join("/ ")` → `9,0 tkm/ 04/27`) — zwei
      Einheiten, davon eine erfundene (**„tkm" schreibt niemand**, und die Mein-Audi-Servicebox
      nannte dieselbe Zahl schon immer „9.029 km", d. h. dieselbe Tatsache war an zwei Stellen
      unterschiedlich formatiert), zwei Schrägstriche mit zwei Bedeutungen, und auf 375 px
      zweizeilig umgebrochen. Beide Regelwerke laufen hier auf dieselbe Auflösung hinaus, live
      nachgelesen: Apple HIG *Layout* („Make essential information easy to find by giving it
      sufficient space… don't obscure it by crowding it with nonessential details") und
      *Typography* („Adjust font weight, size, and color… to help people visualize hierarchy");
      die Audi-CI definiert `.fig` (Audi Type Wide 300) ausdrücklich als *„large numeric
      figures"* — **eine** Zahl, nicht zwei. Umgesetzt: Wert und Kontext getrennt
      (`9.029 km` groß, `vsl. 04/2027` als leisere Zweitzeile), echte Einheit statt „tkm", und
      die Kachel ist jetzt ein `button.tile.tilebtn` mit `.go`-Chevron nach `service:x` — vorher
      eine Sackgasse, obwohl die Service-Seite existiert. `mmjj()` wurde dadurch verwaist (nur
      noch das volle Jahr wird gebraucht) und ist durch `mmjjjj()` ersetzt. Die HU-/Inspektions-
      Zweige sind mit dem Ölwechsel-Zweig zusammengefallen, weil sie nach der Trennung dieselbe
      Form haben. Live geprüft (Tag und Nacht, 375 px): beide Kacheln einzeilig, Tippen öffnet
      Service mit aktivem Zurück-Pfeil.
      **companion-app hatte diesen Fehler nicht** — dort standen die Termine schon als
      Label/Wert/Zusatz-Zeilen. Stattdessen dort der benachbarte Gruppierungsfehler behoben: die
      zwei Service-Zeilen hingen *innerhalb* der Kilometerstand-Kachel (zwei Termine unter einer
      Überschrift, die nichts mit ihnen zu tun hat) und führten nirgendwohin. Jetzt eigene,
      antippbare `Tile variant="button" chevron` → `service`. Geprüft: typecheck sauber, 95/95
      Tests, Build erfolgreich.
- [x] Übersicht: Kilometerstand/Service statt zwei gleichrangiger Kacheln (2026-08-17) — Nutzer
      mochte das Kachelpaar nicht und fragte, wie Apple HIG bzw. die Audi-CI es lösen würden. Beide
      Regelwerke wurden dafür belegt, nicht aus dem Gedächtnis zitiert: die HIG-Seiten „Layout“
      („Differentiate controls from content“; Wichtigkeit über Position und Größe) und „Widgets“
      (dynamische statt statischer Inhalte; „sparse layouts can make the widget seem unnecessary“)
      live von developer.apple.com gelesen, die CI-Regeln aus dem projekteigenen
      `AUDI_CI_ARCHIV_2026-08-13.md` (flache Flächen mit Haarlinien statt gestapelter Karten;
      `.fig`/Audi Type Wide als eigene typografische Klasse für Zahlen; Hierarchie nur über Größe
      und Farbe, da nur die Schnitte 300/400 existieren). Gemessener Befund: beide Kacheln
      166×113px, gleiche 29px-Zahl, gleiche Farbe — obwohl nur die rechte ein Knopf war (erkennbar
      allein an einem 12px-Chevron) und die beiden Zahlen Grundverschiedenes meinen (Ist-Wert vs.
      täglich schrumpfender Countdown). Beide Schulen sagen dazu dasselbe, sie unterscheiden sich
      nur im Mittel. Ergebnis nach drei Iterationen mit dem Nutzer (Zwischenstände jeweils als
      Mockup gezeigt, bevor Code angefasst wurde): das `.duo`-Raster entfällt, stattdessen eine
      führende Kennzahl in einer normalen `.tile` mit Hintergrund (erst flach entworfen, auf
      Nutzerwunsch dann doch als Box) — Zeile „Nächster Service:“, Chevron oben rechts, 40px-Zahl,
      Unterzeile „bis zum Ölwechsel · vsl. 15.04.2027“, Haarlinie, darunter der Kilometerstand als
      `.servicezeile`. Der Chevron sitzt bewusst nur am `.serviceblock`-Knopf, nicht über der ganzen
      Fläche: damit ist der obere Teil das Bedienelement und die Kilometerstand-Zeile reine
      Anzeige (live geprüft — Klick auf den Block öffnet „Service“, Klick auf die Zeile navigiert
      nicht). „Nächster Service“ meint auf Nutzerwunsch **jede** Serviceart; `naechsterTermin()`
      wählte ohnehin schon über Ölwechsel/Inspektion/Hauptuntersuchung hinweg aus, neu ist nur die
      Beschriftung samt `ART_BIS`-Zuordnung für den richtigen Artikel („bis zu**m** Ölwechsel“, aber
      „bis zu**r** Inspektion“). Fällt kein Restkilometerwert an (Hauptuntersuchung hat kein
      km-Ziel), rückt das Datum selbst ins Zahlenfeld, dann bei 30px statt 40px, damit „15.04.2027“
      auf 375px nicht umbricht. Von der Änderung verwaist und deshalb mitentfernt: `mmjjjj()` (nur
      hier benutzt) und die `.duo`-Regeln in beiden Stylesheets. **Bewusst offen gelassen:** der
      Statuspunkt aus der HIG-Variante („alles ruhig“ vs. „bald fällig“) — der Nutzer hat ihn nicht
      übernommen. **Bewusst nicht entschieden von mir:** das volle Tagesdatum statt „04/2027“ war
      Nutzerwunsch; angemerkt wurde dabei, dass die Prognose an einem einzigen Arbeitstag von
      12.04. über 14.04. auf 15.04.2027 wanderte (`oelwechselPrognose()` extrapoliert aus km/Tag,
      jede Odometer-Aktualisierung verschiebt sie), das Tagesdatum also mehr Genauigkeit verspricht,
      als der Wert hat. Live geprüft bei 375px und 1280px: Block und Wertezeile fluchten mit der
      Kachelkante (16→359 bzw. 564→1236), Chevron bündig rechts, Tippfläche 88px hoch, keine neuen
      Konsolenfehler. Deployed als Version `1787007000` (zwischenzeitlich auf Nutzerwunsch von
      `.tile.flat` auf eine normale `.tile` mit Hintergrund umgestellt — `.serviceblock:active
      {opacity:.55}` als Tipp-Rückmeldung ergänzt, da der übliche `.tilebtn`-Hintergrundwechsel
      hier nicht geht), synchron in `installationspaket/`. Entwurf und Begründung als Artefakt:
      „Zwei Kacheln, drei Antworten“.
- [x] Nächster-Service-Box: CI-Icon statt "bis zum/zur ..."-Wortlaut (2026-08-18) — der Nutzer
      lehnte zunächst einen Folge-Entwurf ab, der dieselbe HIG/CI-Systematik auf die "Mein Audi"-
      Service-Vorschau und "Anstehende Termine" auf der Service-Seite anwenden sollte ("I don't like
      it" → per Rückfrage: "Lost information", also zu viel weggelassen) — dieser Vorschlag wurde
      **nicht** umgesetzt, keine Codeänderung daraus übernommen. Stattdessen ein präziser, neuer
      Wunsch für die bereits bestehende Nächster-Service-Box auf der Übersicht: das Wort
      ("bis zum Ölwechsel"/"bis zur Inspektion") vor der Kennzahl entfällt zugunsten der echten Audi-
      CI-Icons `inspection-l/-s`/`oil-change-l/-s` (vom Nutzer als SVG geliefert, aus
      `C:\Users\tobia\Downloads\Icons 2026-08-09 13_04_03\`, verbatim übernommen wie die übrigen
      CI-Icons dieses Projekts — l im 48er-, s im 24er-Grid), links der Zahl platziert; auf
      Nutzerwunsch anschließend nochmal um 30% vergrößert (24px → 31px). Umgesetzt in beiden
      Codebasen: Panel bekam `CI.oilChangeL/-S`/`CI.inspectionL/-S` (neben den bestehenden CI-
      Icon-Konstanten) und ein neues `.fig-zeile`-Wrapper-Div (`display:flex;align-items:center`)
      um Icon + `.fig` in `vHome()`'s Serviceblock; die Nebenzeile zeigt jetzt nur noch
      `vsl. DATUM`. Companion-app bekam die identischen Pfade als neue `SymbolOelwechsel`/
      `SymbolInspektion`-Komponenten in `symbole.tsx` (eigener `RahmenFlaeche`-Wrapper, da diese
      zwei — anders als die generischen Tab-Icons dort — gefüllte Flächen-Icons sind, kein
      stroke-Wrapper) und eine neue `.dm-servicefig`-CSS-Klasse in `screens.css`; `Uebersicht.tsx`
      wählt das Symbol über eine kleine `SERVICE_SYMBOL`-Tabelle nach `service.art`. **Wichtig:** die
      Icons blieben bewusst außerhalb von `design-system/` (Lizenzregel oben — echte Audi-CI-Assets
      dürfen nur in Panel/companion-app, nicht in das markenfreie `@audi-dash/ui`) — die Verdrahtung
      liegt deshalb direkt im jeweiligen Screen, nicht in der gemeinsamen `Fig`-Komponente. Die
      Hauptuntersuchung hat kein eigenes Icon (kein Restkilometer-Ziel, `restKm` ist dort immer
      `null`) und behält `bisText()` als einzige Kennzeichnung — das deckt sich in beiden Codebasen
      genau mit der Bedingung, unter der ein Icon überhaupt existiert. `npm run typecheck`/
      `npm run test` (100/100)/`npm run build` sauber. Live im Docker-Testcontainer geprüft (Version
      `1787012001`, synchron in `installationspaket/`): Ölwechsel-Icon rendert links der "8.999 km"-
      Kennzahl, Unterzeile zeigt nur noch "vsl. 18.04.2027", keine neuen Konsolenfehler.
- [x] Nächster-Service-Box: Hauptuntersuchung-Datum in Zahlengröße + Art-Name statt "bis zur ..."
      (2026-08-18) — Folgewunsch zum Icon-Umbau: das Datum im Zahlenfeld war für die
      Hauptuntersuchung (kein Restkilometer-Ziel) bisher kleiner gesetzt (30px statt 40px), extra
      damit lange Daten wie "15.04.2027" auf 375px nicht umbrechen; der Nutzer wollte stattdessen
      dieselbe 40px-Größe wie bei Ölwechsel/Inspektion, plus darunter statt "bis zur
      Hauptuntersuchung" nur noch das Wort "Hauptuntersuchung" selbst. Vor der Umsetzung am
      laufenden Panel nachgemessen (nicht angenommen): "15.04.2027" misst bei 40px/Audi Type Wide
      182px, der Kachel-Container hat 322px — die ursprüngliche Wrap-Sorge, die zur 30px-Regel
      geführt hatte, bestätigt sich bei den tatsächlichen Container-Maßen nicht. `zahlGroesse` ist
      jetzt fest 40 (Panel `vHome()` und companion-app `Uebersicht.tsx`, `Fig size={40}`
      unabhängig von `restKm`). Da `bisText()`/`ART_BIS` (Panel) und `bisText()`/`BIS_TEXT`
      (companion-app `service.ts`) dadurch ihre einzige Aufrufstelle verloren, wurden beide als
      Orphans entfernt (inkl. des zugehörigen companion-app-Tests in `service.test.ts` — von 100 auf
      99 Tests, kein Funktionsverlust, nur der jetzt toten Funktion beraubt). Live im
      Docker-Testcontainer per DOM-Patch simuliert (Testfixture hat aktuell Ölwechsel als
      nächstgelegenen Termin, nicht die Hauptuntersuchung) und bei 390px Breite screenshotet:
      "06.08.2028" einzeilig bei 40px, "Hauptuntersuchung" darunter, kein Icon (wie vorgesehen — es
      gibt kein CI-Icon für die Hauptuntersuchung). `npm run typecheck`/`npm run test`
      (99/99)/`npm run build` in companion-app sauber. Deployed als Panel-Version `1787012002`,
      synchron in `installationspaket/`.
- [x] "Anstehende Termine"-Box (Service-Seite) umgebaut: drei sichtbar getrennte Blöcke
      (2026-08-18) — auf Wunsch des Nutzers ein neuer Entwurf (Apple HIG + Audi CI, wie beim
      Übersicht-Umbau), mit expliziten Vorgaben: Herstellervorgabe bleibt für Ölwechsel und
      Inspektion möglich, "voraussichtlich ..." bleibt für beide erhalten, alle drei Arten werden
      sichtbar getrennt, die Hauptuntersuchung bekommt bewusst weder Herstellervorgabe noch
      Prognosezeile. Durchlief drei Mockup-Runden als Artefakt ("Drei Termine, drei Blöcke") vor der
      Umsetzung: **Runde 1** abgelehnt ("Major Information is missing"). **Runde 2** ergänzte ein
      vollständiges Feld-Inventar (Tabelle: jedes Element aus `vService()` mit Fundstelle im
      Vorschlag), integrierte den vereinbarten Werkstatttermin in den Hauptvergleich statt als
      Anhang, zeigte den "kein Eintrag im Servicebuch"-Zustand. **Runde 3** (auf drei
      Inline-Kommentare des Nutzers im Artefakt): Ölwechsel-Icon von 20px auf 26px angehoben (füllt
      in seinem 24×24-Raster nur eine 24×10-Fläche statt der fast vollen Höhe des
      Inspektion-Icons — per `getBBox()` nachgemessen, kein exakter Ausgleich möglich ohne das Icon
      unverhältnismäßig breit zu machen), Herstellervorgabe-Zeile auch am Ölwechsel-Beispiel gezeigt
      (vorher nur bei Inspektion), der Hauptuntersuchungs-Block von einem Icon-Platzhalter-Abstand
      auf echt linksbündig geändert. Danach vom Nutzer freigegeben ("Ok, implement it").
      **Umsetzung, beide Codebasen:**
      Panel (`vService()`, `audi-dashboard-app.js`): `<dl class="rows">` durch drei
      `<div class="termingruppe">` ersetzt (Kopfzeile mit Icon + Art-Name + optionalem
      `.tg-chip` für einen vereinbarten Werkstatttermin, optionale `.tg-vorgabe`-Zeile, `.tg-wert`
      als 22px/Gewicht-300-Zahl, optionale `.tg-prognose`-Fußzeile) — Auswahllogik für Wert/
      Fußzeile/Herstellervorgabe unverändert aus dem bisherigen Code übernommen, nur die Markup-
      Struktur geändert. `ART_ICON`/`ART_ICON_GROESSE` (26px Ölwechsel, 20px Inspektion) von
      `vHome()` auf Modulebene gehoben, da jetzt von zwei Stellen genutzt — `vHome()`s eigene
      Icon-Größe (31px, beide gleich) bewusst unverändert gelassen, das ist eine offene, dem Nutzer
      gestellte Rückfrage. Der Werkstatt-Chip nutzt `var(--red)` nur als Text-/Rahmenfarbe, keine
      Füllung — deckt sich mit der bereits bestehenden Projektregel "Rot ist Akzent, nie Fläche"
      (siehe iOS-Overlay-Fix vom 2026-08-13). Deployed als Version `1787013000`, synchron in
      `installationspaket/`, live im Docker-Testcontainer per direkter DOM-Abfrage geprüft (JS statt
      Screenshot, da die Browser-Pane in dieser Sitzung zeitweise nicht kompositierte): alle drei
      Blöcke rendern mit korrektem Text, Icons in 26px/20px, Hauptuntersuchung ohne Icon, keine
      neuen Konsolenfehler.
      companion-app (`Service.tsx`): dieselbe Drei-Blöcke-Struktur, aber eine **echte
      Neustrukturierung, kein 1:1-Kopieren** — die bisherige Kachel hieß "Nächster Ölwechsel" und
      mischte Ölwechsel-, Inspektions- und Hauptuntersuchungs-Zeilen fachfremd in einer gemeinsamen
      `Werteliste`; jetzt "Anstehende Termine" wie im Panel, mit denselben drei `.dm-termingruppe`-
      Blöcken. Neue `SymbolOelwechsel`/`SymbolInspektion`-Komponenten in `symbole.tsx` (bereits für
      die Übersicht angelegt, hier wiederverwendet). Neuer Export `letzterInspektion()` in
      `service.ts` (Gegenstück zu `letzterOelwechsel()`) für den "kein Eintrag"-Zustand der
      Inspektion. **Zwei bewusste, dokumentierte Vereinfachungen gegenüber dem Panel** (vorbestehende
      Lücken, nicht durch diese Änderung verursacht): (1) companion-app hat keine "Termin
      vereinbaren"-Funktion und keine `vereinbart`-Datenfelder für Service-Termine — der Werkstatt-
      Chip entfällt deshalb ersatzlos, es gibt nichts anzuzeigen. (2) companion-app hat kein
      Gegenstück zu `intervalle()`/dem generischen Herstellerintervall-Fallback des Panels; die
      Herstellervorgabe-Zeile wird stattdessen direkt aus `fahrzeug.oel.modus` abgeleitet (Ölwechsel:
      nur bei `modus === "hersteller"`, Inspektion: immer, da sie kein einstellbares Intervall hat —
      deckt sich inhaltlich mit dem Panel-Verhalten, auch ohne dieselbe Funktion zu teilen). Beide
      Punkte sind funktional gleichwertig zum bisherigen companion-app-Stand, nicht neu eingeführte
      Lücken. `npm run typecheck` sauber, `npm run test` 99/99, `npm run build` erfolgreich; die
      bestehenden "Alle Seiten rendern mit Beispieldaten"-Tests rendern die Service-Seite weiterhin
      ohne Absturz. Kein Live-Screenshot in dieser Sitzung (bräuchte einen laufenden Dev-Server samt
      Backend-Anbindung) — strukturell verifiziert über Typecheck/Tests/Build.
      **Zwei Nachbesserungen direkt nach der ersten Live-Prüfung, beide vom Nutzer gemeldet:**
      (1) "Herstellervorgabe" fehlte beim Ölwechsel, obwohl der angezeigte Wert (29.100 km /
      25.07.2028) vom Fahrzeug selbst gemeldet wird (`hatMeldung`/`fahrzeug.oelwechselFaelligTs`).
      Root Cause: die Zeile war bisher ausschließlich an `iv.hersteller` (Panel) bzw.
      `fahrzeug.oel.modus === "hersteller"` (companion-app) gekoppelt — dem *App-eigenen*
      Intervall-Modus aus "Einrichten". Der Bordcomputer kennt diesen App-Modus aber gar nicht;
      eine Fahrzeugmeldung stammt immer aus dem werkseigenen Wartungsprogramm und ist damit per
      Definition Herstellervorgabe, unabhängig vom gewählten App-Modus. Das Testprofil hat den
      Ölwechsel aktuell auf `modus: "eigen"` stehen (10.000 km/12 Monate statt 30.000 km/24
      Monate) — das war korrektes Verhalten der alten Bedingung, nur die Bedingung selbst war zu
      eng. Fix: `herstellervorgabe = hatMeldung || iv.hersteller` (Panel) bzw.
      `oelHatMeldung || fahrzeug.oel.modus === "hersteller"` (companion-app) — dieselbe alte
      Bedingung bleibt für den Fall ohne Fahrzeugmeldung erhalten (App-eigener Fallback auf das
      Herstellerintervall), kommt jetzt aber zusätzlich bei einer echten Fahrzeugmeldung zum
      Tragen. (2) Die Prognose-Fußzeile zeigte "8.999 km, voraussichtlich am 18.04.2027" auch dann,
      wenn in "Einrichten" tatsächlich Herstellervorgabe gewählt ist — dort rechnet die eigene
      Prognose ohnehin schon mit dem Herstellerintervall, die Restkilometerzahl wäre also nur eine
      zweite, verwirrende km-Angabe neben dem Fahrzeug-Wert direkt darüber. Fix: die "X km,"-
      Vorsilbe entfällt, sobald `CAR.oel.modus`/`fahrzeug.oel.modus === "hersteller"` ist — nur beim
      eigenen (kürzeren) Intervall bleibt sie stehen, weil sie dort echte Zusatzinformation ist
      (matcht damit exakt, wie die Inspektion-Fußzeile schon immer aussah, da sie kein einstellbares
      Intervall hat). Mit dem Testprofil (`modus: "eigen"`) bleibt die Kilometerzahl also weiterhin
      sichtbar — korrekt, das ist genau der Fall, für den sie gedacht ist; umschalten auf
      Herstellervorgabe in "Einrichten" würde sie ausblenden. Beide Fixes deployed als Panel-Version
      `1787013001`, synchron in `installationspaket/`, live per DOM-Abfrage geprüft: Ölwechsel-Block
      zeigt jetzt "Herstellervorgabe", keine neuen Konsolenfehler. companion-app: `npm run typecheck`
      sauber, `npm run test` 99/99, `npm run build` erfolgreich.
- [x] **Parity rule eingeführt (2026-08-17):** der Nutzer stellte klar, dass Panel und
      `companion-app/` immer denselben funktionalen Stand haben sollen — auch wenn eine Anfrage nur
      das Panel nennt, das ist reiner Testkomfort (Docker-Instanz), keine Scope-Entscheidung. Regel
      als verbindlicher Absatz direkt unter der Maintenance-Regel oben in dieser Datei verankert.
      Als erste Anwendung sofort im selben Zug die Übersicht-Neugestaltung von oben nach
      `companion-app/` portiert (siehe `Uebersicht.tsx`): `naechsterService()` in `daten/service.ts`
      neu — Gegenstück zu `naechsterTermin()` im Panel, wählt über Ölwechsel-/Inspektions-Prognose
      und die von Hand gepflegte Hauptuntersuchung hinweg den zeitlich nächsten Termin, `bisText()`
      liefert denselben Artikel wie `ART_BIS` im Panel. Die beiden bisherigen Kacheln
      "Reichweite"/"Kilometerstand" nebeneinander plus eine separate "Service"-Kachel mit
      Werteliste wichen einer Reichweiten-Kachel plus einer Service-Kachel mit `.dm-serviceblock`
      (Knopf, nur der obere Teil) und `.dm-servicezeile` (reine Anzeige) darunter — dieselbe
      Control/Content-Trennung wie im Panel. `npm run typecheck` blieb sauber, `npm run test` auf
      100/100 (von 95 — vier neue Tests für `naechsterService()`/`bisText()` in `service.test.ts`,
      einer in `screens.test.tsx`, der mit einem `vi.fn()` als `geheZu` wirklich belegt, dass ein
      Klick auf den Serviceblock navigiert und ein Klick auf die Kilometerstand-Zeile es nicht tut
      — dafür musste `zeige()` in `screens.test.tsx` erst einen injizierbaren `geheZu`-Parameter
      bekommen, vorher war er hart auf `() => {}` verdrahtet), `npm run build` erfolgreich.
- [ ] Fix remaining documentation drift (statistics claim, README gaps, obsolete TODO comment) —
      text-only changes; INSTALL.md's WLAN/TommiG1 drift and stale variable names were fixed
      2026-08-12 (see section B); `DESIGN_REVIEW_2026-08-13.md` and `REVIEW_main_2026-08-13.md`
      list further drift not yet fixed (WLAN references still in `SPECIFICATION.md`,
      `homeassistant/README.md`, the profile template)
- [x] Harden `profil_lesen()` against missing/corrupt `fahrzeugprofil.json` — done on the
      `umsetzung-datametric360` branch (2026-08-11), brought over by the 2026-08-13 merge. Returns
      `None` on a missing or malformed file instead of raising; all 5 call sites
      (`backup.py` ×2, `fahrterkennung.py`, `frontend_veroeffentlichung.py`, `reifenzaehler.py`,
      `tankerkennung.py`) already guard for `None` — verified by grep across the merged tree, no
      caller left unguarded (this was flagged as the merge's main risk in `REVIEW_main_2026-08-13.md`
      §"Was den Branch betrifft").
- [x] Decide whether the 3 audit leftovers get fixed here or only in DataMetric360 — decided
      2026-08-11 (`UMSETZUNGSPLAN.md` Phase 2): swipe-delete keyboard fallback, popup keyboard
      access, and self-hosting Leaflet stay unfixed in this panel and are addressed only in
      DataMetric360.
- [ ] Optional: persist the RAM-only states (trip start, fuel low-water-mark) — deliberately
      deferred; may become moot with the FMM003 switch
- [ ] Upload vehicle photos to `www/bilder/`, set `steuer.faellig` (operational data, not code)

### D) Once the panel is superseded

- [ ] **Archive** `audi-dashboard-app.js`, don't delete (settled decision, architecture §1)

### E) Versicherungs-Details overhaul (planned, not started — noted 2026-08-19)

The owner wants to revisit "Versicherung/Steuer" more broadly next session, beyond the line-editing
work already done (see section C in the changelog above: "Vertrag" tile made fully editable with
add/delete for Selbstbeteiligung). Three separate asks, none scoped or started yet:

- [ ] Store the insurer's own Vertragsdetails PDF against the policy (upload/attach + view later) —
      needs a decision on storage location (mirrors the existing vehicle-photo/backup file handling
      under `www/bilder/`? a new profile-attachment concept?) and where it's surfaced (new tile on
      "Vertragsdetails", or the main "Vertrag" tile)
- [ ] A free-text field for the owner's own manual notes/summary on the insurance (not derived from
      any structured field) — likely a `notiz`-style textarea, panel already has a `.notiz` CSS class
      pattern used elsewhere (see `feld.notiz` in `audi-dashboard.css`) that could apply here
- [ ] Redesign the "Versicherung/Steuer" Übersicht/summary box — **owner explicitly said they don't
      yet know what they want here**; needs a requirements conversation (what should be surfaced at a
      glance vs. moved to detail screens) before any mockup, not just a straight implementation

Not planned/scoped in either codebase yet — start with clarifying questions on the PDF storage model
and the summary-box redesign goals before touching code, per this project's mockup-first convention
for open-ended redesign asks (see the "Anstehende Termine" 3-round mockup process, section C).

### F) Retroactive data import from the HA recorder (built 2026-08-23)

**The retention answer, because it was the owner's actual question.** Two different lifetimes were
being conflated:

- **The app's own records live forever already.** `fahrten.jsonl`, `tankvorgaenge.jsonl`,
  `batteriespannung.jsonl` and `fahrzeugprofil.json` under `/config/audi_dashboard/` have no purge
  path anywhere in `profil.py` — nothing trims them, ever. That was already true before this change.
- **HA's raw sensor history did not.** `recorder.purge_keep_days` defaults to **10 days**, and the
  test instance had no `recorder:` block at all. That default was the real ceiling: it caps how far
  back *any* reconstruction can ever reach, because deleted recorder rows are gone permanently.

`homeassistant/recorder_snippet.yaml` (new) raises it to 365 days with `auto_repack: true`. Measured
on the test instance rather than guessed: 90.858 state rows over 17 days ≈ 5.300/day, 64,7 MB DB →
roughly 1,9 M rows and 1–1,5 GB for a year. 96 % of that traffic is the `cupra_eu_data_act`
integration (~1.737 states per entity per 17 days across ~50 entities), 1 % flespi/FMM003, 2 % the
rest — so the file carries a commented-out `exclude:` list targeting the EU-Data-Act side for anyone
who needs to shrink it. Deliberately **no `include:` block**: an include would stop HA recording
everything else entirely, and since 97 % of this instance is vehicle data it would save almost
nothing while costing every other integration its history.

**The import itself.** `pyscript/historienimport.py` (new) exposes
`audi_dashboard_historie_importieren(start, ende)`. It reads the same sensors the live triggers
watch and derives the same records they would have produced:

- trips from the ignition sensor — contiguous "on" stretches, then merged where the gap is shorter
  than `fahrten_pausenzeit_min`, which reproduces the live pause rule (`task.unique()` +
  `task.sleep()` in `fahrterkennung.py`) after the fact; odometer, GPS start/end filled from the
  other histories; stretches under 60 s dropped as ignition-without-a-drive
- refuels from the tank sensor using the same low-water-mark logic and the same thresholds as
  `tankerkennung.py` (5 l / 9 pp, whichever is more sensitive)
- daily min/max battery voltage, same shape as `batterieverlauf.py`

Re-running is safe by design: trips whose window overlaps an existing trip are skipped (whatever
their origin), refuels dedupe on a 90-minute window, battery days merge in `profil`'s own
min/max update. Created records carry `source: "import"` alongside the existing `ha`/`manual`/`auto`.

**How the history is read, and why.** Via HA's own
`homeassistant.components.recorder.history.get_significant_states` through `task.executor()`, not by
opening `home-assistant_v2.db` with sqlite3 directly. Direct SQL was the tempting option
(`allow_all_imports` is already on, no config change needed) but the recorder schema is HA-internal
and changes between versions; the function is the interface HA itself uses. The cost is one new
config line, **`hass_is_global: true`** in the pyscript block (`configuration_snippet.yaml`), without
which the `hass` name does not exist in pyscript — everything else in the app works fine without it,
only the import reports that it cannot read the history. `significant_changes_only=False` matters:
the default mode drops exactly the small numeric steps that odometer distance and refuel detection
are built from.

**Frontend, both codebases.** Panel: `vImportPopup()` reusing the existing `.beleg-popup` overlay
pattern, opened from a new button under Einstellungen → Einrichten, with Von/Bis
`datetime-local` fields defaulting to the last 30 days, "Importieren" primary and "Abbrechen"
second. The dialog stays open and shows the result rather than closing optimistically — the one
place in the app where that is right, because the result *is* the point of the action. Progress
comes from polling `pyscript.audi_dashboard_import_status` (same pattern as the update status),
since pyscript services return immediately while the work continues in the background.
Companion: `screens/HistorienImport.tsx` using the design system's `Popup variant="form"`, plus
`api.historieImportieren()` / `api.importStatusLesen()`. Deliberately **not** routed through the
offline queue like every other write: the import is a long-running operation whose result is the
reason you invoked it, and a queued run firing later from a dead session would surprise the user —
offline, it simply should not be offered.

**One real bug found and fixed while testing:** `profil.batterieverlauf_tageswert_aktualisieren()`
appended in insertion order. That was invisible while only the live path wrote it (it always adds
today, i.e. always the newest), but the import adds *past* days — they landed after newer ones and
the frontend chart, which draws in file order, would have run backwards. It now sorts by date,
restoring the invariant its own docstring already promised.

**Verification.** The test container is not running while the car is driven (owner's note), so its
recorder holds no real trips. Instead a synthetic history was seeded straight into the recorder DB —
two drives, one with a 5-minute mid-drive ignition gap, one 30-second ignition burst, a two-step
refuel, and two days of voltage — and the import reproduced it exactly: 3 trips (the gap correctly
merged into one 50-minute/40 km trip), 1 rejected as too short, 1 refuel, 2 battery days, distances
matching the seeded odometer to the kilometre. A second run over the same window created 0 and
reported 3 as already present, confirming dedup. Both runs were driven through the real UI in the
browser, not by calling the service directly. `tsc --noEmit` clean, companion tests 106/106 (6 new
for the result-sentence pluralisation), `vite build` clean, HA config check and startup clean.

**Still open / worth knowing:** the import has only ever run against seeded data, never against a
real multi-week recorder history on the production instance — the first real run is the honest test,
especially for how long a full year takes (the UI waits up to two minutes before saying it continues
in the background). Address reverse-geocoding is not attempted for imported trips (`start_address`/
`end_address` stay null, same as live-detected ones before the user edits them).

### H) HACS: the pyscript backend became a real integration (2026-08-23)

Owner asked for it directly ("build the hacs") after deferring it earlier the same day. The
deferral reasoning still held — converting first means debugging the rewrite and the real
environment at once — but the owner overrode it, so the conversion happened.

**What was actually blocking HACS.** Two independent things, both checked against the installed
HACS 2.0.5 rather than from memory:

- **No HACS category installs to `/config/pyscript/`.** The six categories resolve to
  `custom_components/<domain>/`, `www/community/<name>/`, `python_scripts/`, `themes/`,
  `custom_templates/` and `appdaemon/apps/<name>/` (verified in `repositories/*.py` → `localpath`).
  The app needed `/config/pyscript/` **and** `/config/audi_dashboard/` **and** `configuration.yaml`
  entries. This is what the conversion solved.
- **HACS is GitHub-only, and does not work here — permanently, not just today.** `github.com`/
  `api.github.com` are hardcoded throughout; the repo lives on `gitea.nothaft.cloud`. Briefly
  mirrored to `github.com/T130B/DM360` on 2026-08-24, but that changes nothing: **HACS
  categorically refuses private repositories**, confirmed against its own docs
  (hacs.xyz/docs/faq/private_repositories: "Private GitHub repositories can not be used with HACS
  at all... HACS can only get publicly available information") — no token, no signed-in-account
  exception. An earlier note in this file claimed the opposite ("HACS must be signed in with the
  account that owns it"); that was wrong and is corrected here. The repo is **private on purpose**
  (it carries the Audi typeface and the model badges, licensed for this one private install only),
  so making it public to satisfy HACS is not on the table — and since the mirror bought nothing
  (HACS still can't see a private repo, mirrored or not), the owner decommissioned it the same day.
  **Development is Gitea-only from here on; there is no GitHub presence.** `hacs.json` was removed
  (dead weight with no GitHub target to serve), and `manifest.json`'s `documentation`/
  `issue_tracker`/`codeowners` point at the Gitea repo instead.
  `homeassistant/installationspaket/install.ps1` is not a fallback — it is **the only installation
  path**, and the docs (`README.md`, `homeassistant/INSTALL.md`, `installationspaket/ANLEITUNG.md`,
  `VERSIONIERUNG.md`, the script's own header) say so. Don't reintroduce a GitHub mirror or
  `hacs.json` without the owner raising it again — the private/public constraint that killed this
  path hasn't changed and isn't expected to.

**The shape.** `custom_components/audi_dashboard/` at repo root — not for HACS's benefit (see
above, HACS is off the table), just where `install.ps1` and Home Assistant itself expect a
`custom_components` entry to live. Config flow, one
`sensor` platform, 18 services, ~2,900 lines of pyscript ported to ~2,400 lines of integration.
`koordinator.py` holds the runtime state and does the wiring that `@service`/`@state_trigger`/
`@time_trigger` used to do implicitly; the domain modules keep their old names and structure so the
diff stays readable. `modules/` was flattened (it only existed because pyscript treats that folder
specially). Three renames earned their keep: `profil.py` → `ablage.py` (it handles trips and
fill-ups too, the old name was actively wrong), `entitaeten.py` → `zuordnung.py` (avoids collision
with HA "entities"), `frontend_veroeffentlichung.py` → `veroeffentlichung.py`.

**Five install steps disappeared, and none of them by hand-waving:**

| Was | Now |
|---|---|
| `pyscript:` block with `allow_all_imports` + `hass_is_global` | gone — no YAML at all |
| `panel_custom:` block | the integration registers its own panel |
| copy the frontend to `/config/www/` | served from `frontend/` under `/audi_dashboard_static/` |
| long-lived token in `audi_dashboard/ha_token.txt` | recorder API read directly (`verlauf.py`) |
| `pip install pypdf` in the container | `requirements` in `manifest.json`, HA installs it |
| copy `fahrzeugprofil.example.json` by hand | seeded from `vorlage/` on first setup |

Verified on a genuinely fresh throwaway container, not reasoned about: 12 entities, zero trips,
zero fill-ups, zero battery days, profile created from the template, and **pypdf 6.16.2 installed
by HA itself**.

**The naming contract changed, and both frontends had to follow.** `pyscript.audi_dashboard_x` →
`sensor.audi_dashboard_x`, `pyscript.audi_dashboard_y` → `audi_dashboard.y`. The prefix only ever
existed because pyscript owned the domain. Both frontends now carry the names in one table each
(`const.py` ↔ `frontend/audi-dashboard-app.js` `E`/`DOMAIN` ↔ `companion-app/src/api/types.ts`
`ENTITAETEN`/`DIENST_DOMAIN`). **A companion app from before this change finds nothing after the
upgrade** — it must be rebuilt in the same step. The panel can't drift: it ships inside the
integration.

**Three long-standing weaknesses fixed, because the conversion made them cheap:**

1. **The payload no longer hits the recorder database.** `_unrecorded_attributes = {"daten"}` on the
   entity. Only a real entity belonging to an integration can do this — pyscript's bare `state.set()`
   can't, so every publish wrote the complete trip archive to the DB, every 60 seconds. Confirmed by
   querying the history API: only `icon` and `friendly_name` come back. Note the mechanism moved:
   exclusion now comes from `state_info["unrecorded_attributes"]` on the Entity, **not** from a
   per-domain `recorder.py` platform hook (checked in `db_schema.py` of the installed 2026.8.0).
2. **A running trip survives a restart.** `fahrt_start_ts` and the tank low-water mark live in a
   `Store` instead of only in memory. This was a documented data-loss bug.
3. **Trigger rebinding is live.** `@state_trigger` baked the entity ID in at module load, so
   changing ZUENDUNG/KM/TANK_SENSOR needed an HA restart — the setup dialog had a warning and a
   restart button for exactly that. `trigger_neu_binden()` replaces all three; the warning, the
   button and the `neustart` service are gone.

**Fixing (2) exposed a new failure mode, and it was found by testing rather than reasoning.** If the
ignition goes off *while HA is down*, nobody sees the transition — the resumed trip would stay open
forever and the next real trip would be appended to its start. `nach_neustart_fortsetzen()` closes
it at the last recorded ignition timestamp (not "now" — HA may have been down for days), and runs
via `async_at_started` so a not-yet-restored source can't be mistaken for "off". Verified live: an
orphaned trip was closed at 21:12:43, the last value the recorder held.

**The self-updater is gone.** `updateverwaltung.py` cloned a git repo into a staging folder and
copied `pyscript/` and `www/` over the running code — necessary only as long as the app had no
install shape Home Assistant knows. HACS is that shape: it checks, downloads, installs, shows a
changelog and can roll back. A second, homemade update mechanism beside it is more attack surface
than value. The Settings tile now shows the installed version and points at HACS.

**Versioning collapsed to one number.** `manifest.json` `version` is the single source: HA lists it,
HACS compares it, the integration publishes it as `sensor.audi_dashboard_app_version`, it hangs on
the panel URL as `?v=…`, and vite reads it for `__APP_VERSION__` (hard-failing if absent). The
`VERSION` file and the Unix-seconds cache-buster in `audi-dashboard-version.json` are both gone —
two sources for one fact would have drifted, and the comparison would have gone silently wrong. The
new cache breaker is strictly better: it changes only on real changes. `VERSIONIERUNG.md` rewritten.

**Installer got smaller and much safer.** It copies one folder and touches nothing else. The
`configuration.yaml` manipulation — the one way an installer can stop HA from starting, and the
reason the old script needed timestamped backups, a conflict refusal and a read-back rollback — is
gone with the YAML it used to write. What remains: refuses to delete a folder whose `manifest.json`
doesn't claim `domain: audi_dashboard`, reads the manifest back after copying. Tested on a mock
config dir: dry run, first install, update run, and the refusal path.

**Removed from the repo** (recoverable via `git log`): `homeassistant/pyscript/`,
`homeassistant/www/audi-dashboard-*` + `badges/`, `homeassistant/data/`, `homeassistant/install.sh`,
`homeassistant/update.ps1`, the `installationspaket/` copy of everything (the duplicate-and-sync
arrangement is gone — the installer reads the real folder), `configuration_snippet.yaml` (nothing
left to add), `ANLEITUNG.pdf` (stale and not regenerable here; the `.md` beside it is current).
`data/tests/` → `tests/belegparser/`, pointing at the integration's parser copy; 8/8 still pass.

**Do not resurrect the pyscript tree.** If something is missing, port it — running both backends
writes two sets of trips into the same files.

**Don't re-litigate HACS.** Confirmed dead-end, not a gap — see the H) intro above. Nothing about
Gitea-vs-mirrored-GitHub changes it: HACS refuses private repos regardless of where they're
mirrored, and the private/public constraint (licensed Audi assets in the repo) isn't going away.
`install.ps1` is the permanent, only install path, not a stand-in.

**Stale HACS claims found and fixed in code comments/UI text (2026-08-24).** The docs-level cleanup
above happened the same day as this section was written, but a full-repo grep afterward (prompted by
the owner spotting live UI text still claiming "Updates laufen über HACS") turned up leftovers the
first pass missed, because it focused on the standalone docs (`README.md`, `INSTALL.md`, etc.) and
not source comments or UI strings. Fixed: the panel's Einstellungen → Version tile literally told the
user HACS would notice and install updates itself — replaced with the actual mechanism
(`install.ps1`, panel ships inside the integration and can't go stale on its own). Also fixed,
all now pointing at `install.ps1`/"jeder Auslieferung" instead of "HACS"/"HACS-Update": comments in
`audi-dashboard-app.js` (the version-tracking block and the retired-updater history note),
`companion-app/vite.config.ts` (manifest-version header comment), `const.py` (`STATIK_URL`,
`BUENDEL_ORDNER`, `BILDER_ORDNER` comments), `bilder.py` (module docstring), `__init__.py`
(`cache_headers=False` rationale), and a diagram branch in `VERSIONIERUNG.md` that stated "HACS
vergleicht sie gegen das Repository und meldet Updates" as flat fact, contradicting the boxed note
one screen above it. Left alone: `install.ps1`'s own header (already correctly explains the
exclusion), `ota-paket.ps1`'s comment (already correct), and one debugging note in
`audi-dashboard-app.js` (~line 4720) that recalls what was actually running in a specific test
container during a past incident — a historical fact about that container, not a claim about how
this project works, so not in scope for this cleanup. Also out of scope, a different topic entirely:
mentions of the *retired vehicle-data-source* HACS integration `TommiG1/HA_VAG-EU-Data-Act` in older
planning docs (`SPECIFICATION.md`, `COMPANION_APP_ARCHITECTURE.md`, `bauauftrag.md/html`,
`testumgebung/`) — that HACS integration is unrelated to whether HACS can install *this* app; it was
FMM003's predecessor as a data source, already documented as retired elsewhere in those same files.
Manifest bumped to `2026.8.24.3` and `npm run ota` rerun (the panel text change is user-visible).

### The deployed-parity problem, and what was built for it (2026-08-23)

The parity rule (binding, above) guarantees *source* parity: both codebases change in the same
session. It guarantees nothing about what is *running*. After the conversion the two sides are
wired differently in a new way:

- the panel ships **inside** the integration — one `install.ps1` run (the only delivery path — HACS
  is a dead end here, see section H) moves backend and panel together, and it cannot go stale at all
- the companion app is a Capacitor shell with `webDir: "dist"` and **bundled** assets, sideloaded
  through Xcode. It stays on whatever was bundled at signing time, silently, indefinitely

The version comparison above is what makes that visible: the app compares its compiled-in stamp
against `sensor.audi_dashboard_app_version` and says so in the existing `Hinweisleiste`, whose
stated principle is already "nie eine stille Veraltung".

Deliberate choices worth keeping: the comparison is **equality only**, never greater/less — the
version is an identifier, not a number, and ordering it would be false precision that breaks
silently on a format change. Either side missing yields `"unbekannt"` and **no** warning, so an
older backend or an offline start cannot produce a false alarm. And the vite build **fails hard** if
the manifest version is absent rather than emitting an app that cannot detect its own staleness.

**OTA delivery: built and verified end to end, 2026-08-24.** `@capgo/capacitor-updater` 8.51.14
installed (MPL-2.0, peer `@capacitor/core: ^8.0.0` against our `^8.5.0`). Manual mode, not
`autoUpdate` (that stays `false` on purpose — an update must never swap the UI out from under
someone mid-entry): `companion-app/src/daten/ota.ts` wraps `download({url, version, checksum})` +
`set({id})`, wired to a button in the Einstellungen screen (`otaUpdateVerfuegbar` /
`otaAusloesen`). `notifyAppReady()` runs from a `useEffect` in `AngemeldeteApp` (`App.tsx`) —
deliberately there and not in `main.tsx`, so a crash before React actually renders never gets
confirmed and the plugin's `appReadyTimeout` rollback (`capacitor.config.ts`, 20s, more generous
than the 10s default because first load also fetches profile/trips/refuels) takes over.

The bundle itself lives **inside the integration**, not under `/local/` as first sketched:
`custom_components/audi_dashboard/frontend/app/{bundle.zip,bundle.json}`, built by
`companion-app/scripts/ota-paket.ps1`, served at `/audi_dashboard_static/app/bundle.zip`. Reason
for the relocation: `/local/` would have needed its own delivery step that `install.ps1` doesn't
touch and could be forgotten; inside the integration folder it travels with every install/update
automatically, same as the panel. `sensor.audi_dashboard_app_version`'s `daten` gained a `buendel`
field (`koordinator._buendel_lesen()`) carrying `{version, sha256, bytes, gebaut, url}` — `null`
when no bundle is published, a normal state, not an error.

One real bug the build caught: `[IO.Compression.ZipFile]::CreateFromDirectory` on Windows
PowerShell 5.1 (.NET Framework, not Core) writes **backslashes** as path separators for nested
entries — a spec-violating zip that iOS would not have unpacked correctly. Caught by inspecting the
actual zip contents after the first build, not assumed; fixed by writing entries by hand via
`ZipFile::Open` + `CreateEntryFromFile` with `\` replaced by `/`. The plugin verifies SHA-256 over
the *downloaded zip itself* before unpacking (`CapgoUpdater.swift` `calcChecksum`, confirmed by
reading the plugin source, not the README) — `ota-paket.ps1` hashes the same file it writes, so the
two can't drift.

Verified against the running test container: entry reload picks up code changes but **not** a
manifest version bump (HA caches the `Integration` object — needs a full restart, same lesson as
the pyscript-removal step in section G); after a real restart, `sensor.audi_dashboard_app_version`
published the new manifest version and a complete `buendel` block; the zip served over HTTP hashed
identically to `bundle.json`'s `sha256`; `unzip -l`/`-t` confirmed 13 entries, `index.html` at the
root, no backslashes, no corruption.

**A second, quieter bug surfaced 2026-08-24 when a manifest-only metadata change (the GitHub-removal
commit) prompted the question of whether it needed a version bump.** It didn't — but the question
exposed that nothing enforced `frontend/app/bundle.json`'s version staying in sync with
`manifest.json`'s on a *real* bump. Trace the failure: bump manifest to V2, ship via `install.ps1`,
forget `npm run ota` → backend publishes `app: "V2"` live, but `bundle.json` (frozen at build time)
still says V1, and the already-installed app is still V1. `Hinweisleiste` compares app-vs-server and
correctly says "you're outdated, update" (V1 ≠ V2) — but the Update screen's `buendelPasst()`
compares app-vs-*bundle* and says "you're current" (V1 = V1, the bundle's stale number). Two
contradictory messages, and no way to actually reach V2 via OTA until someone notices and reruns
`npm run ota` — the exact kind of silent-until-discovered coordination bug this project has fixed
repeatedly elsewhere (trip status, tire counter, `edited_fields`). Fixed at the one chokepoint every
delivery already passes through: `install.ps1` now reads `frontend/app/bundle.json`'s version right
after reading the manifest's, warns loudly and adds the mismatch as the **first** Restliste item if
they differ (a missing bundle file is not a mismatch — OTA is optional, silence is correct then).
Verified both ways on a mock config dir: matching versions print "OTA-Bündel passt zur Integration"
and add nothing to the Restliste; a deliberately bumped manifest (tested via a temporary edit,
reverted byte-for-byte after, diffed against `HEAD` to confirm) produces the warning and lands it as
Restliste item 1. `VERSIONIERUNG.md`'s "Was du tun musst" now lists the OTA rebuild as its own
numbered step (2, before shipping) instead of omitting it entirely.

Capacitor's `server.url` pointed at HA was considered and **rejected**: it would make the app as
current as the panel, but the shell then cannot boot without reaching HA, gutting the deliberately
built offline queue (`api/warteschlange.ts`).

**`install.ps1` couldn't authenticate to the Samba share, and gave a misleading error (found and
fixed 2026-08-24, during the owner's first real deployment attempt).** The owner's Samba add-on was
running and reachable — confirmed via its own log (`smbd`/`nmbd` started cleanly) and via
`Test-Path`/`net use` from an interactive PowerShell session, which worked. Yet `install.ps1` kept
reporting "Ziel nicht erreichbar" for the exact same path. Root cause: `Test-Path` on a UNC path
**never prompts for credentials** — without an already-authenticated SMB session to that server, a
password-protected share just returns `false`, with no error and no password prompt. The script had
no credential handling at all; it only ever worked by accident, piggybacking on a session the user
happened to have already established by hand (Explorer, or a manual `net use`). Once that session
dropped — confirmed happening organically here when the Samba add-on itself restarted mid-session —
the next run failed again with the same unhelpful message, even though path and password were both
correct. Two side findings along the way, also worth remembering: (1) `Test-Path` against a UNC path
can fail silently for reasons other than credentials, e.g. process elevation — a `net use` session
established in a non-elevated shell is invisible to a process launched via "Run as administrator",
because they're different logon contexts; `Installieren.cmd` needs no elevation and should always be
run plainly. (2) Add-ons on Home Assistant OS often bind to their **own IP**, separate from HA
Core's — the Samba add-on here answered on a different address than the one shown on
Settings → System → Network, confirmed via the add-on's own broadcast log line and by successfully
loading the HA frontend on that same IP.

Fixed at the point of failure: if the target path isn't reachable, `install.ps1` now prompts for a
username (defaulting to `homeassistant`, the Samba add-on's account) and password, clears any stale
connection to the same path first (`net use $Ziel /delete`), and authenticates
(`net use $Ziel /user:... ...`) before retrying `Test-Path`. Only a genuine auth/path failure after
that throws. No more manual `net use` pre-step required. Verified the script still parses
(`PSParser]::Tokenize`) after the edit; end-to-end verification pending the owner's next real run.

### G) Fresh-install audit + installer hardening (2026-08-23, before the first real deployment)

> **Historical from here on.** This describes the pyscript-era package, which section H replaced
> later the same day. The *findings* still matter — the blank-delivery rule and the safety
> properties carried over into the new installer — but the files it names (`pyscript/`, `www/`,
> `data/`, the `configuration.yaml` block) no longer exist. Kept as the record of what was wrong
> and why the current shape looks the way it does.

Owner is about to install on the real HA OS instance and asked for two things: that the one-click
package produce a genuinely blank app, and that it carry no risk to the existing HA system.

**The one real contamination bug.** `pyscript/modules/einstellungen.py` shipped with two hardcoded
entity IDs from a long-gone test instance — `ZUENDUNG_SENSOR =
"binary_sensor.testzone_fmm003_engine_ignition_or_acc_status"` and `BATTERIE_SENSOR =
"sensor.testzone_fmm003_external_power_voltage"` (note `testzone_`, older even than the current test
container's `testcar_b9_…`). Invisible during all testing, because the test instance's own
`entitaeten.json` overrides both. On a fresh install with no overrides they would have been the
*effective* values — and worse than merely dangling: `fahrterkennung.py` guards trigger registration
with `if einstellungen.ZUENDUNG_SENSOR:`, explicitly built so an unset sensor registers no trigger at
all. A set-but-nonexistent value defeats exactly that guard. Both are now `""` like every other field,
and the file header was rewritten — it still claimed the EU-Data-Act integration was gone, which
stopped being true when `cupra_eu_data_act` appeared.

Everything else in the package was already clean, verified rather than assumed: no `fahrten.jsonl`,
no `tankvorgaenge.jsonl`, no `batteriespannung.jsonl`, no `entitaeten.json`, no real tokens (the
`ha_token` hits are path constants), and `fahrzeugprofil.example.json` carries only model specs —
FIN, Kennzeichen, Erstzulassung, HU, insurance numbers and all counters are empty or zero.

**Verified by actually doing it**, not by reading the installer: a throwaway `ghcr.io/home-assistant/
home-assistant:stable` container, the package copied in exactly as `install.ps1` does it, pyscript
config entry added, restarted. Result: `/config/audi_dashboard/` held only `fahrzeugprofil.json`,
`shell_beleg_parser.py` and an empty `belege/`; no trip, fuel or battery file existed at all; zero
ERROR lines in the log.

**Installer safety, audited and then hardened.** The audit found the good news first: `install.ps1`
contains no `Remove-Item` anywhere, no `robocopy /MIR`, never touches `.storage/`, never reads or
writes `automations.yaml`/`scripts.yaml`/`secrets.yaml`, and never restarts HA. The only file it
changes outside its own folders is `configuration.yaml` — which is also the only way it could stop HA
booting. Three gaps closed there:

- the fixed `configuration.yaml.bak` was overwritten on every run, so a second run destroyed the
  pristine original. Backups are now timestamped, and the plain `.bak` is written only once
- after writing, the file is read back and checked (still at least as long as before, marker block
  present exactly once, prior content byte-identical); any deviation **rolls back automatically**
  from the timestamped backup and moves the step to the manual to-do list
- the header now states the safety properties explicitly, so they can be checked rather than trusted

Tested end to end against mock config dirs: `-Pruefen` wrote nothing; a real run preserved a foreign
`light:` group and left `automations.yaml` byte-identical (SHA-256 compared) while appending the
block; the output parses as valid HA YAML with all foreign keys intact; a second run left exactly one
block and preserved `fahrzeugprofil.json`; and against a config that already had its own `pyscript:`
and `panel_custom:` the file came back byte-identical, as designed.

**The genuine remaining risk is disk space, not the installer.** `recorder_snippet.yaml`'s 365 days
is ~1–1,5 GB, and on HA OS with an SD card or small eMMC a full disk stops HA from starting — nothing
to do with this app. The snippet header and the installer's to-do list now say so plainly, tell the
user to check Einstellungen → System → Speicher first, and recommend `purge_keep_days: 90` when space
is tight (enough for the "catch up on the past" purpose, since the import moves data into the app's
own permanent `.jsonl` files anyway).

**Two user-reported bugs fixed 2026-08-23, both worse than they looked:**

*"Fahrzeug fährt" never changed.* `standortZustand()` inferred the driving state from
`TRIPS[0].status === "offen"` — two independent errors stacked. First, `"offen"` does not mean
"driving"; it means **data incomplete** (distance still missing, see `fahrtabschluss_logik.py`) — a
trip created by `_fahrt_beenden()` *when it ends*. A trip that never got its odometer, e.g. because
no `KM_SENSOR` was mapped at the time, stays `"offen"` forever. Second, `TRIPS[0]` is the **oldest**
trip, not the newest: `fahrten_veroeffentlichen()` passes `profil.fahrten_lesen()` through in file
order, which is ascending. Together: the oldest ever-incomplete trip pinned the display to "fährt"
permanently. The test instance had one open for 18 days.

Fixed at the source rather than by patching the index: the backend now publishes `zuendung` in
`fahrzeugstatus` from the already-mapped `ZUENDUNG_SENSOR` — the same signal `fahrterkennung.py`
treats as authoritative, so display and detection cannot disagree by construction. `null` (no sensor
mapped) renders "Fahrzustand unbekannt" rather than claiming "steht".

The companion had the mirror-image bug: `liveZustandLesen()` read `status["zuendung"]` (never
published) and `status["lat"]`/`status["lon"]` (backend publishes `standort_lat`/`standort_lon`), and
`Fahrzeug` declared none of those fields — so `LiveFahrt` showed "Das Fahrzeug steht" permanently and
never a position. Fields added to `Fahrzeugstatus`/`Fahrzeug` and the adapter, so the type now
catches this class of bug — which its own header comment already warned about from a previous
occurrence.

*"Standortzugriff verweigert" was misleading.* It sits directly under the vehicle name where the
distance-to-car goes, but describes **this device's** browser geolocation — it read as if the car's
location were unavailable. Now "GPS offline", as the owner asked. Permission denial keeps its own
string ("GPS-Freigabe fehlt"): it is the one case with a different remedy, and "GPS offline" would
send the user looking at signal instead of at a permission toggle.

**Also fixed this round (panel-only):** the "Teilen" pill on the location sheet was invisible in
day mode — `.standort-pille` used `background: var(--tile)` while the sheet under it uses
`var(--tile-deckend)`, and the iOS overlay sets *both* to `#FFFFFF` in day mode. Measured contrast
was 1.00:1, white on white. Both pills now follow the app's own button language (`.aktion` /
`.aktion.primaer`): outline for the secondary, `var(--fg)` fill for "Route". Text contrast measured
17–21:1 in both themes afterwards. This also retired `--line-strong` — a *line* colour — being used
as a button fill. No companion-app port: the location sheet's share/route pills do not exist there,
which the parity rule exempts as panel-only.

### I) Ölwechsel/Inspektion could vanish entirely with a mapped sensor and no Servicebuch (found and fixed 2026-08-24)

Owner reported: "no inspektion and ölwechsel shown even if there is the right sensor and
herstellervorgabe." Both were true — sensor mapped correctly, "Herstellervorgabe" selected — and the
row still just said "kein Eintrag im Servicebuch" (no Servicebuch entry).

**Root cause, identical in both codebases.** The Ölwechsel/Inspektion display combines two
independent sources on purpose (see the header comment in `companion-app/src/screens/Service.tsx`):
the vehicle's own reported due date/km (`fahrzeugMeldungRoh()` / `fahrzeug.oelwechselFaelligTs` etc.,
sourced from the mapped HA sensor) is meant to stand *alongside* the app's own Servicebuch-based
forecast, not depend on it. But the actual gating checked only the Servicebuch side:
- Panel (`frontend/audi-dashboard-app.js`, `vService()`): `const iv = intervalle(t); if (!iv) return
  ...„kein Eintrag im Servicebuch"...` — and `intervalle()` returns `null` whenever there's no
  Servicebuch basis, full stop. The vehicle-reported `fm`/`hatMeldung` was computed *after* this early
  return, so a mapped sensor with valid data never even got read for a fahrzeug with an empty
  Servicebuch — exactly the state of a freshly set-up car.
- companion-app (`src/screens/Service.tsx`): same shape, `{oelHatEintrag ? ... : "kein Eintrag im
  Servicebuch"}` — gated purely on a Servicebuch entry existing, ignoring `oelHatMeldung`/
  `inspHatMeldung` computed one line above it. `src/daten/service.ts`'s `naechsterService()` (feeds
  the Home-screen "Nächster Service" tile) had the same gap one level up: it only ever built
  candidates from `oelwechselPrognose()`/`inspektionPrognose()` (both Servicebuch-only), never
  falling back to `fahrzeug.oelwechselFaelligTs`/`inspektionFaelligTs`.

**Fixed by reordering, not by adding a new data path** — the sensor data was already being read
correctly by the backend and delivered to both frontends; only the display-gating ignored it.
Panel: `fm`/`hatMeldung` now computed *before* the early-return check, which now reads `if (!iv &&
!hatMeldung)`; `anzeige`/`herstellervorgabe` guarded for `iv` possibly being `null` (both remain safe
because the code paths that dereference `iv` only run when `hatMeldung` is false, and the new guard
guarantees `iv` is non-null whenever that's the case). companion-app: `oelHatEintrag ||
oelHatMeldung` / `inspHatEintrag || inspHatMeldung` as the render condition; `naechsterService()`
gained an `else if (fahrzeug.oelwechselFaelligTs)` / `...inspektionFaelligTs` fallback branch per
service type, added only when the Servicebuch-based prognose is `null` — a real Servicebuch entry
still wins when both exist (own forecast is finer-grained, factoring actual driven km/day). The
Hauptuntersuchung path is unaffected either way: `fahrzeugMeldungRoh("Hauptuntersuchung")` always
returns `{ts: null, km: null}`, so `hatMeldung` is always `false` for it — same behavior as before.

Verified: `tsc --noEmit` clean, all 120 companion-app tests pass (including 3 new regression tests in
`service.test.ts` covering the meldung-only fallback for both service types and confirming a real
Servicebuch prognose still takes priority when both are present), panel JS re-checked with `node
--check`. `manifest.json` bumped to `2026.8.24.2` and `npm run ota` rerun (own rule from section H,
enforced by `install.ps1` since the same day) since this touched both the panel and the companion-app.

### J) Self-update: the integration pulls its own updates from Gitea (built 2026-08-24)

**Why.** `install.ps1` (section H) stopped being a reliable update path: Windows Smart App Control
blocks its execution, and unlike SmartScreen there is no "run anyway" override — once fully
enabled, Smart App Control can only be turned off by reinstalling Windows. The owner's actual daily
pain wasn't the block itself but the whole cycle it forced: open Gitea, download a zip, unpack it,
run the (now-blocked) installer, re-enter the Samba password every time (see section H's Samba-auth
fix earlier the same day). Owner chose, from three options laid out (free workarounds first / code-sign
the installer / have Home Assistant pull its own updates), the third: **the integration now updates
itself.** `install.ps1` remains necessary for the *first* install only — the integration has to
already be running before it can update itself — which is the rare case; the daily-update tedium is
what this closes.

**Safety design — stricter than install.ps1, because it runs inside what it replaces.**
`install.ps1` is an external process against a foreign HA instance; this feature's code lives
*inside* `custom_components/audi_dashboard/` and replaces that very folder while its own service
call is still executing. `aktualisierung.py`'s module docstring carries the full reasoning; the
shape:
1. Download and verification happen entirely in a sibling staging folder
   (`custom_components/audi_dashboard_update_staging/`) — the live folder is never touched until the
   download is confirmed complete and plausible.
2. The swap is `os.rename`, not delete-then-copy: the live folder is renamed to
   `audi_dashboard_backup` (kept, not deleted — reversible) before the staging folder is renamed into
   its place. If the second rename fails, the first is undone automatically rather than leaving a
   half-replaced folder.
3. The downloaded `manifest.json` is checked for `domain == "audi_dashboard"` and a `version` field
   before either rename runs — matches install.ps1's own "won't touch a folder that isn't
   provably ours" rule (section G/H).
4. **No automatic reload or restart.** A reload triggered from inside the same service call that's
   still running would risk being interrupted by its own reload. The UI shows a "restart Home
   Assistant" message instead — the same pattern install.ps1's Restliste already uses.
5. Nothing outside `custom_components/` is touched — same invariant as install.ps1.
6. The check step (a Gitea network call) runs only on explicit button press, never on the
   coordinator's normal publish tick — same "no unsolicited background action" stance as the OTA
   `autoUpdate: false` decision.

Renaming the very folder the running code was imported from is safe on Linux (this project runs on
Home Assistant OS / Docker): Python holds no open file handles on `.py` sources after import
completes, and `os.rename` only operates on path strings, not live handles.

**Token storage.** No prior mechanism existed for secrets in this integration — `config_flow.py`'s
`entry.data` was always `{}`, no `OptionsFlow` existed. Added `AudiDashboardOptionsFlow`
(Settings → Devices & Services → Audi Dashboard → Configure), one masked field
(`CONF_GITEA_TOKEN` in `const.py`), stored in `entry.options` — HA's own encrypted config-entry
store, never `configuration.yaml`. A read-only-scoped Gitea token is enough; the form text says so.

**What was built:**
- `custom_components/audi_dashboard/aktualisierung.py` — `version_pruefen()` (one lightweight Gitea
  Contents-API call for just `manifest.json`, no repo download) and `update_installieren()`
  (downloads the full repo archive via Gitea's archive endpoint — Gitea has no single-folder
  download — delegates to the blocking `entpacken_pruefen_tauschen()` via
  `hass.async_add_executor_job`). The blocking function takes all paths as parameters rather than
  reading module constants directly, specifically so it's testable against a temp directory without
  touching the real integration folder.
- Two new services (`update_pruefen`, `update_installieren`), registered in `dienste.py` following
  the existing `SCHEMATA`/`behandler`/`_als_dienst()` pattern; both zero-field, both catch
  `aktualisierung.AktualisierungsFehler` and turn it into a user-readable message instead of an
  unhandled exception.
- `koordinator.py`: `alles_veroeffentlichen()`'s `E_APP_VERSION` publish was factored out into its
  own `app_version_veroeffentlichen()` method and extended with `integration_update`. This holds the
  *last* check/install result (`self.letzte_update_pruefung`) — deliberately **not** recomputed on
  every publish tick, only ever set by the two new service handlers, so normal operation triggers zero
  Gitea calls.
- Both frontends got a new "Integration-Update" tile (companion-app: `Einstellungen.tsx` +
  `IntegrationUpdateAngabe` type in `api/types.ts` + `integrationUpdate` in `DatenKontext.tsx`; panel:
  new tile next to the existing Version tile in `audi-dashboard-app.js`, backed by a new
  `INTEGRATION_UPDATE` global read from `daten.integration_update`, wired to the existing
  `data-*`/`serviceRufen()`/`ereignisseVerdrahten()` click-dispatch pattern) — not an extension of the
  existing OTA "App-Update" tile, which is a different concept (the Capacitor iOS shell going stale)
  that doesn't apply to a browser-rendered panel.

**Verified, honestly scoped by what this session's sandbox could actually run:**
- The safety-critical pure logic (`entpacken_pruefen_tauschen()`) has 7 unit tests
  (`tests/aktualisierung/test_aktualisierung.py`, `unittest`, same style as
  `tests/belegparser/test_shell_beleg_parser.py`) covering: successful swap with the old version
  preserved in the backup folder, `__pycache__` cleanup, wrong domain rejected, missing `version`
  field rejected, missing `manifest.json` rejected, corrupt zip rejected, archive missing the
  expected path rejected — and every rejection case asserts the live folder is byte-for-byte
  untouched. Run inside the project's existing throwaway `ghcr.io/home-assistant/home-assistant:stable`
  test container (`audi_ha_test`) via `docker exec`, since no Python interpreter exists in the
  session sandbox itself.
- The complete integration (config flow, both services, koordinator changes, both frontends) was
  deployed into that same container and restarted from a clean state, twice, ending on
  `Audi Dashboard 2026.8.24.4 eingerichtet` with zero errors or tracebacks — confirms the
  `OptionsFlow`/service registration/coordinator wiring is structurally correct against a real,
  current (2026.8.0) Home Assistant.
- companion-app: `tsc --noEmit` clean, all 127 tests pass.
- **Not verified**: an actual successful download+swap against the real private Gitea repo, since
  that needs the owner's own token — entering it is the owner's action via the HA UI, not something
  to hand to an assistant in chat. The failure paths (missing token, bad token, unreachable Gitea) are
  covered by the unit tests and code review, not by a live call against Gitea. First real end-to-end
  use is the owner's to do once a token is configured — worth a first careful try on the test
  container before the real instance, same caution as every other install.ps1-era change in this
  project.

**Docs updated to stop calling install.ps1 "the only way":** `README.md`, `VERSIONIERUNG.md` — both
now say install.ps1 is the only way *to install*; the self-update is now the preferred way *to
update* an already-running instance.

**Two fixes found during the owner's own first live test (2026-08-24, same day).** First attempt
against the test container ("nothing changed") turned out to be a real user error caught by log
inspection, not a bug: the check was clicked before the token had actually been saved in the
Options Flow — `dienste.py`'s log showed exactly why (`Kein Gitea-Token hinterlegt`), confirming the
error path works. But this surfaced a genuine observability gap: `update_pruefen`/
`update_installieren` only ever logged on *failure* — a successful check was silent, so "nothing in
the log" was ambiguous between "not clicked yet" and "worked fine." Fixed by adding an `_LOGGER.info`
line on the success path of both services. Separately, the owner asked for the panel's idle-state
text ("Ein Gitea-Zugriffstoken muss zuvor... eingetragen sein") to only appear as part of an actual
error, not as a permanent reminder cluttering the tile before the first check — removed from the
idle branch; the same information already surfaces via `integration_update.fehler` when it's
actually true. companion-app never had this line in the first place, so no parity fix needed there.

**A real bug found the same day, from a genuinely confusing error report.** After a successful
install-then-restart cycle, a later "Update installieren" attempt failed with:
`Konnte den bestehenden Ordner nicht sichern: [Errno 2] No such file or directory:
'/config/custom_components/audi_dashboard_backup' -> '/config/custom_components/audi_dashboard_backup'`
— note both sides of the `os.rename` are the *same path*, which shouldn't be possible given
`INTEGRATIONSORDNER`/`_BACKUP_ORDNER` are built from clearly different string literals. Root cause,
found via `docker logs`: the log line's logger name was `custom_components.audi_dashboard_backup.dienste`,
not `custom_components.audi_dashboard.dienste`. Home Assistant scans **every** folder directly under
`custom_components/` for a `manifest.json` and loads whatever `domain` it declares — by folder
content, not folder name. The `audi_dashboard_backup` folder left behind by a successful swap is a
byte-for-byte copy of the integration, manifest included, so across a restart HA loaded it as a
*second* `audi_dashboard` integration. Python then imported its `dienste.py` as its own module
(`custom_components.audi_dashboard_backup.dienste`), whose `__file__`-derived `INTEGRATIONSORDNER`
*is* the backup folder — making `_BACKUP_ORDNER` collapse onto the same path for that rogue copy.
This duplicate-domain state also explained two other symptoms reported the same session: the panel
intermittently doing nothing visible on "Auf Update prüfen"/"Update installieren" (whichever
integration's service registration won the race), and the frontend getting stuck on the bootstrap
"Lädt …" screen after a restart (confirmed live: with the stray `audi_dashboard_backup` folder still
present, `audi_dashboard` appeared **twice** in the loader's warnings and the panel never finished
loading; deleting the stray folder and restarting fixed both immediately).

**Fix**, in `entpacken_pruefen_tauschen()`: right after the backup rename succeeds, its
`manifest.json` is itself renamed to `manifest.json.bak` — structurally invalid as an integration, so
HA's scanner skips it. On the rollback path (second rename fails), the `.bak` is restored to
`manifest.json` *before* the backup folder is renamed back into place, so a restored live folder is
never left without a valid manifest. Covered by two new unit tests (manifest absent from the backup
folder after a successful swap; manifest correctly restored after a forced rollback via a mocked
`os.rename`) — `tests/aktualisierung/test_aktualisierung.py` now has 8 tests, all passing. Verified
live: deployed the fix, deleted the stray backup folder from the test container, restarted — single
`audi_dashboard` loader warning, clean `eingerichtet` line, frontend loads normally. Manifest bumped
to `2026.8.24.7` (see section M below, same version bump covers both fixes).

### K) vCard import for the workshop contact (built 2026-08-24)

Service → Autohaus (gear icon) now has a "vCard importieren" button next to the existing form
fields, opening a file picker for a `.vcf` contact exported from a phone/desktop address book. Reads
`FN`/`ORG` (name, `FN` preferred), `TEL`, `EMAIL`, and `ADR` (the seven-part vCard address —
PO-Box/extended/street/locality/region/postal-code/country — joined into one readable line, since
the form's address field is a single string). Deliberately not a full vCard parser: only the four
fields the Autohaus form already has. Unfolds RFC 6350 §3.2 folded lines (CRLF + one WSP removed
entirely, not replaced with a space) and un-escapes `\,`/`\;`/`\n`/`\\` before use. Multiple
`TEL`/`EMAIL`/`ADR` lines: first one wins.

- Panel: `vcardParsen()` in `audi-dashboard-app.js`, wired into `vWerkstatt()`'s new hidden
  `data-vcard-import` file input via the existing central `change` listener — same
  parse-then-`hinweis()`-on-error pattern as the existing JSON profile/backup import handlers.
  Updates `CAR.service.autohaus` fields directly and calls the existing `profilSpeichern()` (saves
  immediately, matching this field's existing per-keystroke-save behavior).
  Same isolated-function shape as `entpacken_pruefen_tauschen()` in section J's self-updater: parse
  logic takes no dependency on `HASS`/global state, so it's directly testable.
- companion-app: new `src/screens/vcard.ts` (`vcardParsen()`, 7 unit tests in `vcard.test.ts`
  covering folding, escaping, ORG fallback, first-wins on repeats, and the two "nothing readable"
  error cases), wired into `Werkstatt()` in `Service.tsx`. Unlike the panel, this only populates the
  local `entwurf` draft state — the existing explicit "Speichern" button still does the actual save,
  matching this screen's existing edit-then-confirm pattern rather than the panel's live-save one.
  Empty fields from the vCard never overwrite non-empty draft values.

Verified: `tsc --noEmit` clean, all 134 companion-app tests pass (127 + 7 new), panel JS re-checked
with `node --check`. Manifest bumped to `2026.8.24.5`, `npm run ota` rerun (both frontends changed).

### L) Self-update UX: loading feedback and a real restart button (2026-08-24, same day)

**The self-update feature (section J) got its first genuine live test against the real private
Gitea repo this same day**, and it worked — confirmed directly in the test container's log across
several real attempts (`Update installiert: Version 2026.8.24.5`, no manual file copying involved).
That's the first real end-to-end confirmation beyond the unit tests and structural container-restart
checks section J could offer on its own.

That same live test exposed two real UX gaps, both raised directly by the owner:

1. **No feedback during or after the ~15–25 second install** (confirmed from the log's own
   timestamps between a "Prüfung" and the matching "installiert" line). The button just sat there
   with no state change — indistinguishable from a hang, and after success, the version still read
   "2026.8.24.4 installiert" because that field only updates after a real restart, which nothing in
   the UI was telling the user to do next beyond a paragraph of text.
2. **A separate, initially confusing "Lädt …" hang the owner saw and asked about.** Traced by
   reproducing it directly: `.lade-spinner`/"Lädt …" is this project's own **existing** top-level
   bootstrap loading screen (shown whenever `DATEN_GELADEN` is false — i.e., no live connection to
   Home Assistant yet), not anything related to the update button specifically. Clicking the new
   "Jetzt neu starten" button and watching it live in a browser reproduced the *exact* same screen —
   because a `homeassistant.restart` call genuinely does drop the frontend's connection until HA
   finishes rebooting, and this is the correct, expected screen for that. The most likely explanation
   for the owner's *original* sighting (before this button existed): the Docker Desktop restart from
   earlier in the same session dropped the connection the same way. Not a bug — but the fact that it
   was indistinguishable from one was the actual, valid complaint: nothing signposted that a restart
   was in progress versus something being stuck.

**Fixed with a three-state UI, researched against Apple HIG's guidance on indeterminate progress**
(unknown-duration network operations get a running indicator, not just a disabled control; on
completion the UI leads straight to the next required action instead of leaving the user to find it
themselves):
- **Prüfen/Installieren, in progress** — a small spinner + "Prüfe …"/"Installiere …", reusing the
  panel's own pre-existing `.lade-spinner` (originally built, per its own comment, for the exact same
  "don't let 'still loading' look like 'stuck'" reason at app bootstrap) and a new equivalent
  `.dm-spinner`/`.dm-ladezeile` pair added to companion-app's `screens.css` (that side had no spinner
  at all before, only a button-text swap).
- **On success** — the tile switches directly to "Installation abschließen - Jetzt neu starten",
  which calls `homeassistant.restart` (a core HA service any admin could already trigger via
  Settings → System → Restart — this button doesn't grant a new capability, it surfaces an existing
  one at the point it's actually needed). Its own click shows "Home Assistant startet neu …";
  no `.finally`-style cleanup after that call succeeds, since the connection normally drops before
  the promise would ever resolve — only a genuine rejection (service refused, etc.) reaches the
  catch block.
- **On a real request-level failure** (not the already-handled "no token"/"bad token" case from
  section J, which returns normally with `fehler` set — this is Gitea unreachable, the websocket
  itself failing, or any other exception that never made it back from Home Assistant at all) — this
  was previously silent in companion-app (a `try/finally` with no `catch` re-throws into an
  unhandled promise rejection). Added explicit `catch` blocks on all three actions in both frontends,
  each surfacing its own message rather than one shared silently-stale error field.

- Panel: `INTEGRATION_UPDATE_LAEUFT` (`"pruefen" | "installieren" | "neustart" | null`), the
  `data-update` click handler rewritten from `serviceRufen()` (fire-and-forget) to direct
  `HASS.callService()` promise chains so the loading flag can wrap them; `vEinst()`'s tile rewritten
  with the three states above.
- companion-app: `updateNeustartLaeuft`/`updateAktionFehler` state and `updateNeustartAusloesen()` in
  `Einstellungen.tsx`; new `homeAssistantNeuStarten()` in `api/index.ts` (`dienstAufrufen("homeassistant",
  "restart")` — note the domain isn't `DIENST_DOMAIN`, this is a core HA service, not one of this
  integration's own).

Verified live end-to-end in the test container via browser automation: triggered the real restart
button, watched the connection drop into the bootstrap "Lädt …" screen exactly as reasoned above,
confirmed via `docker logs` that Home Assistant actually rebooted
(`Audi Dashboard 2026.8.24.6 eingerichtet` after restart, version now genuinely current — not just
the pre-restart file swap). companion-app: `tsc --noEmit` clean, full suite still green. Manifest
bumped to `2026.8.24.6` and `npm run ota` rerun per the standing rule in `VERSIONIERUNG.md` — both
frontends changed (new spinner/restart-button behavior is real, visible UI, not internal-only).

### M) Übersicht/Service mismatch on a blank install: two real bugs (2026-08-24, same day)

The owner tested on a **blank installation** (no Servicebuch entries yet) and found two bugs:

1. **Service showed the vehicle's own km/date; Übersicht showed something else (or nothing).** Root
   cause was worse on the panel than in companion-app: `termine()` (panel) built its candidate list
   purely from `letzterEintrag()` (a real Servicebuch entry) — with none, `km`/`datum`/`ziel` all
   stayed `null`, so `vHome()` (Übersicht) rendered a broken/empty service block, while `vService()`
   already had its own separate `meldungsPrognose()` fallback and displayed correctly. companion-app's
   `naechsterService()` did already call `meldungsPrognose()` as a fallback, but paired the *raw*
   signed `fahrzeug.oelwechselFaelligKm` with a *computed* projected date, which could read
   inconsistently with `Service.tsx`'s own primary line. Fixed by adding the same meldung-based
   fallback to `termine()` (new block right after `oelTermin`/`inspTermin` are built: `if
   (termin.basis) return;` then reads `oelMeldungsPrognose()`/`meldungsPrognose()`), and by having
   `meldungsPrognose()` always return `restKm` (`Math.abs(faelligKm)`) so both codebases use one
   consistent value instead of recomputing it differently at each call site. Verified live: emptied
   the test container's `service.buch` array (backed up first, restored after), confirmed Service and
   Übersicht showed byte-identical numbers (8.687 km / 28.03.2027) where Übersicht previously would
   have shown nothing usable at all.

2. **The Ölwechsel forecast ignored the owner's own (shorter) interval from "Einrichten" until a
   Servicebuch entry existed** — it silently kept showing the manufacturer's own countdown instead,
   with no restKm shown either. Root cause: `meldungsPrognose()`/`fahrzeugMeldungRoh()` only ever
   relay the vehicle sensor's own manufacturer-interval countdown; nothing about them reads
   `oel.modus`/`oel.km`/`oel.monate`. Without a Servicebuch entry there's no known "last real oil
   change" to anchor a custom-interval projection from — the owner chose (asked directly, see
   conversation) to estimate one anyway: back-calculate an assumed last-service point as
   `(manufacturer's own due point) − (manufacturer's own interval length)`, then project the owner's
   shorter interval from that same assumed point. An approximation, not exact — it can never be as
   good as a real Servicebuch entry — but it's the only way to show a number that respects the
   owner's chosen interval before the first entry exists, instead of silently ignoring the choice.
   Implemented once in `service.ts`
   (`meldungsPrognoseEigenesIntervall()` + `oelMeldungsPrognose()`, the latter dispatching to the
   existing `meldungsPrognose()` unchanged when `modus === "hersteller"`) and mirrored 1:1 in the
   panel (same two functions, same math, `Date`-based month arithmetic instead of `service.ts`'s
   ISO-string `monatePlus()` since the panel's own `monatePlus()` only accepts `TT.MM.JJJJ` strings
   and the vehicle meldung's timestamp is ISO). Both `Service.tsx`/`vService()`'s footnote for this
   fallback case now show `restKm` alongside the date when the owner's interval is shorter than the
   manufacturer's, exactly like the existing Servicebuch-based prognose already does. Inspektion is
   unaffected — it has no owner-selectable interval, always the manufacturer's own.

   Only Ölwechsel has a selectable interval, so this only applies there. If `oel.herstellerKm`/
   `herstellerMonate` are themselves unknown, both functions degrade to the plain (unchanged)
   `meldungsPrognose()` rather than guessing further.

Verified: companion-app — 4 new tests for `meldungsPrognoseEigenesIntervall()`/`oelMeldungsPrognose()`
(restKm/date math, the "already overdue by the owner's interval" clamp-to-zero case, no-fahrten
fallback, graceful degradation without hersteller values), full suite green at 142/142, `tsc --noEmit`
clean. Panel: `node --check` clean; live-verified in the test container using the exact same
blank-install simulation as bug 1 above — with `service.buch` emptied and the container's real
`oel` settings (`modus: "eigen"`, `10.000 km`/`12 Monate`, manufacturer `30.000 km`/`24 Monate`), the
footnote showed **8.700 km, voraussichtlich am 23.07.2027** — the derived number from
`28.700 − 30.000 + 10.000`, using the vehicle's real raw meldung — closely matching (not identical to,
as expected for an approximation) the real Servicebuch-based value of 8.687 km / 28.03.2027 seen with
the Servicebuch restored. Restored the container's real profile afterward; no live data was left
altered.

Manifest bumped to `2026.8.24.7` (covers this section and section J's manifest-neutering fix above —
one version bump, both landed the same day); superseded by `2026.8.24.8` in section N below the same
day. `npm run ota` rerun once, covering both this section's and section N's frontend changes together
(see section N).

### N) Sicherheit neu gestaltet: verriegelt / Türen & Klappen / Fenster & Dach / Licht (2026-08-24, same day)

The owner asked for a rework of the "Sicherheit" screen (`vSicherheit()`/`Fahrzeugstatus.tsx`), which
until now showed one flat list of every door/window/heckklappe/haube check with no grouping and no
lock, roof, or light status at all. Requested: four named collector rows — **Fahrzeug verriegelt**
(new lock sensors, state "abgeschlossen"), **Türen und Klappen geschlossen** (with an arrow to a
detail view of every door, Motorhaube, and Kofferraum individually), **Fenster und Dach geschlossen**
(Dach = Schiebedach), **Licht ausgeschaltet** (Standlicht, showing the text **"Kein Licht"** for the
good state, not a generic "in Ordnung"). The owner also supplied a concrete mockup: green
checkmark-circles instead of the old plain-color dot, only the second row carrying a chevron.

Went through plan mode given the cross-codebase, backend+frontend scope. Before planning, checked the
real entities available in the test container's `core.entity_registry` (`cupra_eu_data_act` platform)
to ground the sensor-role design in what actually exists rather than guessing: per-door
`binary_sensor.*_door_lock` (`device_class: lock`), `..._tailgate_lock`, `..._hood_lock`, `..._sunroof`
(`device_class: window`), `..._parking_lights` (`device_class: light`). HA's own convention for all of
these is `off` = the good state (locked/closed/no light) — the exact same `state == "off"` → `ok=True`
check this integration already uses for door/window sensors. No new check pattern needed, only new
sensor roles.

**Backend:**
- `einstellungen.py` — five new `Sensorzuordnung` fields: `TUERSCHLOSS_SENSOREN` (4 positions, mirrors
  `TUER_SENSOREN`), `HECKKLAPPE_SCHLOSS_SENSOR`, `HAUBE_SCHLOSS_SENSOR` (paired with the existing
  open-sensors), `DACH_SENSOR`, `LICHT_SENSOR`. Matching `FELDER` catalog entries under the existing
  `"sicherheit"` group — the Setup-Menü is entirely `FELDER`-driven (confirmed: its group headers come
  straight from this list), so the new roles appeared there with zero extra frontend work.
- `veroeffentlichung.py`'s `_sicherheitscheck()` — extended with the five new checks, each entry now
  additionally tagged `"gruppe"`: `"verriegelt"` (all 6 locks), `"tueren_klappen"` (4 doors + Motorhaube
  + the renamed "Kofferraum" — was "Heckklappe"; the underlying `HECKKLAPPE_SENSOR` field itself is
  unchanged, so an existing assignment isn't silently broken by the relabel), `"fenster_dach"` (4
  windows + Dach), `"licht"` (Standlicht alone). `gesichert` is untouched — `all()` over the full flat
  list is mathematically identical to "AND of all four group results," so no logic change, just more
  entries in the same list.
- **Consequence flagged to the owner**: because every entry in the flat list (including brand-new,
  unassigned ones) counts toward `gesichert`, any existing installation shows "Zustand nicht
  vollständig bekannt" for the overall status until the three new roles are assigned in Setup — same
  "unknown beats assumed-good" principle this project has followed throughout, but worth knowing before
  it looks like a regression.

**Frontend (both codebases):** a small grouping helper (`sicherheitsGruppe()` in the panel,
`gruppenErgebnis()` + filter in companion-app) turns the flat list into a group's aggregate `ok`
(`null` the moment one entry is unknown, or if the group has zero entries at all — never `true` via an
empty-array vacuous truth). Both `vSicherheit()`/`Fahrzeugstatus.tsx` rewritten to the four-row layout;
each row's own label text changes with its state (e.g. "Kein Licht" / "Licht eingeschaltet" / "Licht
unbekannt") rather than pairing a fixed label with a separate status word — matches the mockup, which
shows no secondary value text at all. New checkmark-circle indicator (`statusKreis()` in the panel,
`StatusKreis` component in companion-app's `bausteine.tsx`) — green check / red cross / amber
question-mark SVG in a filled circle, replacing the old plain dot for this screen only; the rest of the
app keeps `.dot`/`.dm-punkt` unchanged. New route `"tuerenklappen"` in both routing tables
(`ZURUECK`/`navigation.ts`), reachable only from the "Türen und Klappen geschlossen" row; its detail
page is the old flat-list style filtered to just that group (`vTuerenKlappen()`, new
`screens/TuerenKlappen.tsx`).

**Verified:**
- Backend: `python3 -m py_compile` on both changed modules, clean restart in the test container with a
  single `audi_dashboard` loader warning (no duplicate-domain regression from section J/M's fix).
- companion-app: `tsc --noEmit` clean, full suite green at 145/145 (added: grouping/unknown-propagation
  test, chevron-navigates-only-this-row test, detail-page-shows-only-its-group test; updated the one
  pre-existing test and the `beispieldaten.ts`/`profilAdapter.test.ts` fixtures for the now-required
  `gruppe` field on `SicherheitsPunkt`).
- Panel: `node --check` clean.
- Live end-to-end in the browser against the test container: before assigning the new sensor roles, all
  three new rows correctly showed "unbekannt" (amber) and the overall status read "Zustand nicht
  vollständig bekannt" — confirming the flagged consequence above is real and correctly wired, not
  theoretical. Opened Setup, scrolled to "Sicherheit": all five new fields were **already
  auto-suggested** to the exact real entities found earlier (the existing stichworte/device_class
  matching worked without any manual searching) — saved (one pre-existing, unrelated "doppelt
  zugeordnet" warning about `Front left door`/`Front left window`, present before this change, dismissed
  via the existing "Trotzdem speichern" override). After saving: "Fahrzeug nicht verriegelt" showed red
  (the demo lock entities report unlocked — correct, not a bug), "Türen und Klappen geschlossen" and
  "Fenster und Dach geschlossen" showed green, "Licht ausgeschaltet" showed green. Clicked the chevron on
  row 2: navigated to "Türen und Klappen" showing all four doors, Kofferraum, and Motorhaube individually,
  all green "zu" — confirmed the detail page and the "Kofferraum" relabel both work.

Manifest bumped to `2026.8.24.8`. `npm run ota` rerun (`scripts/ota-paket.ps1 -Bauen`): picked up
`2026.8.24.8` automatically from the manifest, built clean, `bundle.zip` (233.244 Bytes) + sha256 +
`bundle.json` written — covers both this section's and section M's frontend changes, since neither had
triggered a bundle rebuild yet. `Installieren.cmd` picks the bundle up automatically; no separate step
needed for a self-update-based deployment.

**Same-day follow-up from live owner feedback (2026.8.24.9):**
- **"Kein Licht" reverted to "Licht ausgeschaltet"** — the owner's original wording was right; my
  interpretation of their earlier parenthetical note was wrong. Fixed in both frontends.
- **Locks simplified to one sensor**: dropped `HECKKLAPPE_SCHLOSS_SENSOR`/`HAUBE_SCHLOSS_SENSOR`
  entirely (owner: central locking means individual locks can't be operated separately, so checking
  them adds nothing), and `TUERSCHLOSS_SENSOREN` (4 positions) collapsed to a single
  `TUERSCHLOSS_SENSOR` — the driver's door lock stands for the whole vehicle, same reasoning. `"Fahrzeug
  verriegelt"` is now one sensor check, not six. No frontend code changes needed for this — both
  `sicherheitsGruppe()`/`gruppenErgebnis()` only ever consume the flat, `gruppe`-tagged list generically,
  never reference specific sensor-role names.
- **Real, previously-undiscovered bug found via the owner reporting "Nächster Service still empty" on a
  genuinely blank instance** (Servicebuch cleared AND `erstzulassung` also blank - the owner's own test
  container ended up in exactly this state): `naechsterTermin()`'s comparator, `(a, b) => a.ziel -
  b.ziel`, silently coerces a `null` `ziel` to `0` (the Unix epoch) in JS arithmetic. A candidate with
  genuinely no data (no Servicebuch entry, no vehicle meldung, and for Hauptuntersuchung specifically no
  `erstzulassung` to fall back to) therefore sorted as "nearest" ahead of a *different* candidate that
  had a real, computed date — because epoch-1970 always beats any real future date arithmetically. This
  starved out Ölwechsel/Inspektion even when section M's meldung-fallback had correctly computed a real
  number for them; the tile just showed "–" under "Hauptuntersuchung" instead. Not a regression from
  section M — this comparator predates it and was always latent, just needed a candidate with a
  genuinely absent `ziel` to surface. Fixed by filtering out `ziel === null` candidates before sorting,
  returning `null` only when literally none of the three have a usable date (matching `vHome()`'s
  existing, previously-dead `if (!t)` "kein Eintrag im Servicebuch" branch, which can now actually be
  reached). companion-app's `naechsterService()` was checked and confirmed unaffected: it only ever
  pushes a candidate when a real computed value exists (no null-`datum` candidates ever enter the array)
  and sorts ISO date *strings* via `localeCompare()`, not raw arithmetic — structurally immune to this
  class of bug.
- **Root cause of the browser not showing my fixes at first**: `audi-dashboard-app.js` is fetched by the
  frontend with a cache-busting `?v=<manifest version>` query string. Editing the file on disk without
  also bumping the manifest version left the URL unchanged, so the browser kept serving a cached copy
  (confirmed via `performance.getEntriesByType('resource')` showing `transferSize: 0`) even across a
  forced navigation and a hard-reload keystroke. Bumping the version forced a real refetch. Worth
  remembering for any future panel-JS-only fix: a version bump isn't just the project's documentation
  rule, it's load-bearing for actually seeing the change in a running browser tab.

Manifest bumped to `2026.8.24.9` for this follow-up round.

**A second same-day follow-up round (2026.8.24.10 / .11), more live owner feedback:**
- **Locks simplified further to a single sensor**: `TUERSCHLOSS_SENSOREN` (4 positions) collapsed to
  one `TUERSCHLOSS_SENSOR` — owner: the central locking system locks/unlocks every door together, an
  individual lock can't be operated separately, so the driver's door alone represents the whole
  vehicle's lock state. "Fahrzeug verriegelt" is now exactly one sensor check. No frontend changes
  needed (same reason as the Kofferraum/Motorhauben-Schloss removal above: both grouping helpers only
  ever consume the flat, `gruppe`-tagged list, never a specific sensor-role name).
- **`vSicherheit()`'s route title renamed from "Sicherheit" to "Fahrzeugstatus"** in the panel, matching
  what companion-app's `navigation.ts` already called this page (`TITEL.sicherheit`) — the two frontends
  had drifted apart on this one label. The `FELDER` catalog's `"sicherheit"` *sensor-role group* heading
  (Setup-Menü section, a different concept entirely — sensor categorization, not a page name) is
  untouched.
- **A real, previously-undiscovered bug in `naechsterTermin()` (panel), found from the owner reporting
  "I deleted everything in Servicebuch, why isn't Ölwechsel shown on Übersicht?"**: the sort comparator
  `(a, b) => a.ziel - b.ziel` coerces a `null` `ziel` to `0` (the Unix epoch) under JS arithmetic. On
  this owner's test container, both the Servicebuch *and* `erstzulassung` ended up empty at once, so the
  Hauptuntersuchung candidate's `ziel` was genuinely `null` (no entry, no `erstzulassung` fallback
  either) — and epoch-1970 sorts before any real future date, so HU won "nearest" over Ölwechsel even
  though section M's meldung-fallback had correctly computed a real number for Ölwechsel. The tile
  rendered "–" under "Hauptuntersuchung" instead of the real Ölwechsel figure. Not a regression from
  section M — this comparator predates it and was always latent, just needed a candidate with a
  genuinely absent `ziel` to expose it. Fixed by filtering out `ziel === null` candidates before
  sorting, returning `null` only when literally none of the three have a usable date (the existing
  `if (!t)` "kein Eintrag im Servicebuch" branch in `vHome()` can now actually be reached — it never
  could be before, since `termine()` always returns exactly three entries and the old comparator always
  produced *some* truthy result). companion-app's `naechsterService()` was checked and confirmed
  structurally immune: it only ever pushes a candidate when a real computed value exists, and sorts ISO
  date *strings* with `localeCompare()`, never raw arithmetic on a possibly-`null` value.
- **"Licht ausgeschaltet" reverted back from "Kein Licht"** — the owner's original wording was right;
  my read of their earlier parenthetical was wrong. Fixed in both frontends' idle/ok-state label.
- **Cache-busting gotcha hit again, worse this time**: after the `naechsterTermin()` fix, the browser
  kept showing the *old* broken behavior even after a forced navigation **and** a hard-reload keystroke
  (`ctrl+shift+r`) — `performance.getEntriesByType('resource')` still showed `transferSize: 0` for
  `audi-dashboard-app.js` afterward, meaning even a hard reload didn't bypass this particular cache.
  Only bumping the manifest version (changing the `?v=` query string HA embeds when registering the
  panel's script URL) forced a real refetch. Confirmed this isn't just a documentation nicety: **any**
  panel-JS-only edit needs a version bump to actually be visible in a running browser tab, full stop —
  a hard reload is not a reliable substitute.
- **Wide/desktop layout headline fixed** (`audi-dashboard-ios.css`, `@container (min-width:860px)`
  block): sub-pages reached via the back arrow (Standort, Service, Fahrzeugstatus, Identität und
  Technik, everything with `.back.on`) were rendering their title at `font-size:26px` — the owner
  described this as "bold" in wide/desktop mode versus "not bold, centered" on the phone layout. There
  is no actual `font-weight` bump anywhere in this stylesheet (`.title` is `font-weight:400`
  everywhere, confirmed by grepping the whole file) — the 26px size alone, against an app that mostly
  uses weight-300 body text (see `VERSIONIERUNG.md`/working-conventions "font weights 300/400 only"),
  reads as heavy at a glance even at the *same* weight as every other title. This also violates the iOS
  Human Interface Guidelines pattern this stylesheet otherwise deliberately follows: a "large title"
  belongs to root/tab pages (`.title{font-size:34px}`, unscoped by `.back.on`, left untouched — the
  owner didn't flag `Mein Audi`'s own big top-level title as wrong), while pushed/detail pages keep a
  small, standard title regardless of screen size. Fixed by dropping the back-button-page override to
  `font-size:17px` — the exact same value already used for these same pages in the narrow/phone layout
  (`.phone:has(.back.on) .title` at the file's un-scoped, phone-default rule) — for genuine
  cross-breakpoint consistency, not just a smaller number. Left `text-align:left` as-is: that's a
  property of the wide layout's sidebar+column grid (`.topbar{justify-content:flex-start}`,
  `.head{align-items:flex-start}`, both still scoped to `.back.on` and untouched), consistent with how
  the *also-left-aligned* top-level large titles behave in this same layout — normal desktop-app
  convention, not the trait the owner was actually flagging. Verified live at an actual `1280×800`
  viewport (the container-query breakpoint needs a real wide render, not just the browser tool's
  `desktop` preset, which turned out to stay narrow) — before the fix, "Service"/"Standort" rendered
  visibly larger than their phone-layout counterparts; after, both match.

Manifest bumped to `2026.8.24.11` across this round. `npm run ota` rerun once more at the end to keep
the OTA bundle's embedded version aligned with the manifest (no further companion-app source changes
after the "Licht ausgeschaltet" text fix earlier in this same round, so this rebuild only updates the
stamped version number, not the bundle contents).

**A third same-day fix (2026.8.24.12): the update-button scroll jump, specifically in wide/desktop
mode.** The owner reported the page visibly jumps when pressing "Auf Update prüfen"/"Update
installieren" on a large screen. Reproduced live at `1280×800`: clicking the button while scrolled deep
into "Einstellungen" (`scrollTop` ~1773) landed at `scrollTop` ~1517 after the click resolved — a real,
non-zero shift, not the element being replaced (`main#view` stayed `isConnected: true` throughout, so
it wasn't a full re-mount either) and not simply `scrollTop` reset to 0 the way section L's
narrow-mode work had assumed for this kind of bug. `render()`'s own scroll-preservation
(`v.scrollTop = gleicheAnsicht ? merkeScroll : 0`) should apply here (same route, same id) and normally
does — the update flow specifically still lost position, most likely because the service call also
triggers a *separate*, second re-render path outside `render()`'s own bookkeeping (`sensor.audi_dashboard_app_version`
changing state reaches the embedded panel through Home Assistant's own `set hass()` plumbing, not
through this app's `route`/`letzteAnsicht` mechanism at all) — plausible but not conclusively traced
before the owner asked to move on rather than keep chasing the exact mechanism. Fixed pragmatically
instead of surgically: the `data-update` click handler for `pruefen`/`installieren` now captures
`$("#view").scrollTop` once before acting, and re-asserts it via `requestAnimationFrame` after *each*
of its own `render()` calls (both the immediate one and the one in `.finally()`) — this corrects the
position regardless of what else might be moving it, without needing to identify or touch whatever the
second path turns out to be. Verified live at `1280×800`: repeated the exact same scroll-deep-then-click
sequence that reproduced the bug: **no visible jump**, page stayed on the same section
before and after. (Separately noticed, not fixed, not in scope of what was asked: the check's *result*
only shows up in the currently-open tab after a full page reload, even though it's correctly persisted
server-side immediately — confirmed via `docker logs` showing the check completed and a subsequent
fresh page load showing the right "Verfügbare Version" — likely because nothing in this flow re-reads
`sensor.audi_dashboard_app_version`'s `daten` attribute into `INTEGRATION_UPDATE` and re-renders when
that entity's state changes out from under the open tab. Pre-existing, not caused by this fix, not
reported by the owner as a problem — worth a look if it ever is.)

Manifest bumped to `2026.8.24.12` for this fix.

**A fourth same-day fix (2026.8.24.13): the two "needs a manual page refresh" reports the owner filed
right after — turned out to be the exact gap flagged (but not investigated) at the end of the previous
paragraph.** Two distinct reports, one shared root cause on the panel side:

1. **"Update installieren" needs a manual page refresh to see the restart button.** Traced to
   `datenLaden()`: it unconditionally reads `sensor.audi_dashboard_app_version`'s `daten.integration_update`
   into the module-level `INTEGRATION_UPDATE` variable on every tick, but only calls `render()` inside the
   `geaendert` block — which only compares `profilState`/`fahrtenState`/`tankState`/`statusState`/`battState`
   identity, never `versionState`. A version-only state change (exactly what `update_pruefen`/
   `update_installieren` produce) updated the in-memory value but never triggered a re-render on its own —
   the UI only caught up whenever some unrelated entity happened to change next, which in practice meant
   "never, until a manual reload re-initializes everything from scratch." Fixed by tracking `letzterVersionState`
   the same way the other five states are tracked, and calling `render()` directly whenever `versionState`'s
   identity changes and nothing else already triggered one (a small `if (versionGeaendert && DATEN_GELADEN) {
   render(); return; }` right after the existing `geaendert` block, left untouched).
2. **The "Home Assistant startet neu …" spinner also needs a manual page refresh after the restart
   actually completes.** Root cause was different: `INTEGRATION_UPDATE_LAEUFT` is set to `"neustart"`
   right before calling the `homeassistant.restart` service, and the *only* place that ever cleared it
   back to `null` was the `.catch()` error handler on that same call — deliberate, per section L's original
   comment, since a successful restart normally disconnects the page before the success path could run
   anyway. But nothing ever picked it back up on the other side: once the websocket reconnects after HA
   comes back, `set hass()` fires again (`HASS` was never nulled out while disconnected, so this isn't the
   `erster`/first-connect path), but no code ever reset the stuck `"neustart"` flag, so the spinner stayed
   forever. Fixed by listening once for the underlying `home-assistant-js-websocket` connection's own
   `"ready"` event (fires on every successful (re)connect, confirmed live via
   `HASS.connection.addEventListener`) — wired in `set hass()`, guarded by a new module-level
   `VERBINDUNG_UEBERWACHT` flag so it's only attached once rather than once per `hass` push — and clearing
   `INTEGRATION_UPDATE_LAEUFT`/re-rendering there if it was still `"neustart"`.

**Checked companion-app for the same two bugs (parity rule) rather than assuming React's own re-render
model made it immune:** bug 1 does *not* reproduce there — `updatePruefenAusloesen()`/
`updateInstallierenAusloesen()` in `Einstellungen.tsx` already `await neuLaden()` right after the service
call resolves, which re-fetches `appVersionAngabeLesen()` directly over REST and updates React state
synchronously, no push-event dependency. Bug 2, however, **does** reproduce identically: `updateNeustartLaeuft`
is local `useState` in `Einstellungen.tsx`, deliberately left out of the `try/finally` (same reasoning as
the panel), and nothing else ever reset it — `DatenKontext.tsx`'s own reconnect handling
(`api.live.aufVerbindung()` → `neuLaden()` on `"verbunden"`) only refreshes the *shared* `integrationUpdate`
context value, which this screen-local boolean doesn't derive from. Fixed with a small `useEffect` in
`Einstellungen.tsx` watching the already-available `verbindung` context field, clearing
`updateNeustartLaeuft` when it flips to `"verbunden"` while still `true`.

**Verified (panel, first pass):** `node --check` clean. Live in the test container at `2026.8.24.13`:
clicked "Update installieren" (Gitea test remote genuinely offered `.12`, since that's what had last been
pushed there) — the tile transitioned on its own from "Installiere …" straight to "Version 2026.8.24.12
installiert … Jetzt neu starten" with **no page reload**, confirming bug 1 fixed. Clicked "Jetzt neu
starten" and waited for the container to fully restart — this is where a third, previously-undiscovered
bug surfaced (below), which blocked a clean confirmation of bug 2 until it was also fixed.

**A third bug found while verifying bug 2 for real (2026.8.24.14), not a new report — an already-known,
already-commented race in the code that a genuine backend restart finally exercised end to end.** After
the real `homeassistant.restart` completed, the browser tab reconnected but the panel stayed stuck on the
static "Lädt …" placeholder indefinitely — not the Einstellungen page with a lingering spinner (bug 2's
literal symptom), but back on Übersicht, showing nothing. Traced to: Home Assistant's `panel_custom`
mechanism recreates the `<audi-dashboard-panel>` custom element from scratch after a real backend restart
(confirmed live — a fresh `_aufbauen()` run, fresh skeleton DOM), but the JS *module* itself is not
reloaded, so all its module-level state (`CONFIG`/`CAR`/`DATEN_GELADEN`/etc.) survives untouched from
before the restart. The fresh element's first `datenLaden()` call didn't reliably produce a `render()`
(the exact scenario `go()`'s own existing comment already describes: *"Bekanntes Race … auf einem langsam
startenden Backend kann die App auf 'Lädt …' hängen bleiben … der bislang einzige beobachtete Ausweg war
ein Tabwechsel"*), and — unlike `go()`, which unconditionally calls `render()` on every tab click regardless
of this race — nothing about `_aufbauen()` itself ever painted anything beyond the static skeleton.
Confirmed live: clicking any tab immediately showed full correct data (proving `CONFIG`/`CAR` were fine in
memory all along — only the *paint* was missing), exactly matching `go()`'s own workaround comment.
Fixed the same way `go()` already does it, just made unconditional instead of click-triggered: `_aufbauen()`
now calls `render()` immediately after building a fresh skeleton, if `DATEN_GELADEN` is already `true` (i.e.
this is a rebuild after already having loaded once, not the very first paint — that case is correctly left
to `datenLaden()`'s own arrival). This is the real, complete fix for "the app doesn't recover cleanly after
HA restarts without user interaction" — bug 2's spinner-not-clearing report was one visible symptom of it;
this fix (plus the earlier `"ready"`-listener reset of `INTEGRATION_UPDATE_LAEUFT`) is what makes the whole
class of "just restarted, tab open, nobody touches it" scenario self-heal.

**Verified (panel, final pass) at `2026.8.24.14`:** `node --check` clean. Triggered a real
`homeassistant.restart` directly (`HASS.callService("homeassistant", "restart", {})` from the open tab, no
button click, to isolate this from the update-install flow specifically), confirmed via `docker logs` that
the integration set up a second time end to end (`s6-rc` stop/start sequence, "Audi Dashboard 2026.8.24.14
eingerichtet" logged twice). The browser tab — never manually reloaded, never clicked — automatically showed
full real content (vehicle name, range, "Nächster Service", …) the moment it reconnected. No stuck "Lädt …",
no manual refresh, no tab click needed. This also confirms bug 2 properly this time (the spinner-clearing
`"ready"` listener from the first pass runs as part of the same reconnect path this test exercises).
- companion-app: `tsc --noEmit` clean, full suite still green at 145/145 (no behavior change needed
  testing beyond the existing suite for its own bug-2 fix — a reconnect-driven reset of already-tested
  local state, not new business logic; the deeper element-recreation race is panel-specific, since
  companion-app is a normal React SPA that doesn't get its component tree torn down and rebuilt by an
  external host on backend restart).

Manifest bumped to `2026.8.24.13`, then `2026.8.24.14` once the deeper race above was found and fixed in
the same live-testing session. `npm run ota` not rerun yet for this round — only the `useEffect` addition
in `Einstellungen.tsx` touches companion-app source (the panel-side fixes don't affect the OTA bundle,
which ships companion-app only), no OTA bundle rebuild triggered until the owner asks or the next round
bundles it together with something else.

---

## Working conventions (observed — keep them)

- German is the project language: identifiers, comments, commits, UI texts. Exceptions:
  `design-system/` uses English props/JSDoc (Claude Design audience) — and **this file**, which is
  English by rule.
- Decisions are logged, including rejected ones (see the Traccar section of the architecture
  doc); superseded sections stay in place marked "ÜBERHOLT" rather than being deleted.
- Numbers in `de-DE` format; font weights 300/400 only, never ≥600 (only those cuts exist).
- Real vehicle/movement data stays local: it lives in Home Assistant under
  `/config/audi_dashboard/`, never in this repo. Only the placeholder template
  (`custom_components/audi_dashboard/vorlage/fahrzeugprofil.json`) is versioned; the real receipt
  PDFs under `tests/belegparser/belege/` are gitignored.
- **There is no second copy of the code to keep in sync any more.** Until 2026-08-23,
  `installationspaket/` duplicated `pyscript/`, `www/` and `data/`, and every change had to be
  mirrored by hand — the older entries below still say so. The installer now reads
  `custom_components/audi_dashboard/` directly. Do not reintroduce a copy.
- Security principle: HA is never publicly exposed; only narrowly scoped surfaces (MQTT broker,
  proxy allowlist) may be exposed, each by explicit decision.
