diff --git a/AGENTS.md b/AGENTS.md
index bd0217a..f4dd5d1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -42,7 +42,10 @@ dead-ends in the bootstrap retry loop behind the reported hanging "Lädt …" sc
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).
+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).
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.
@@ -1322,16 +1325,61 @@ wraps the web app for iPhone; a PWA home-screen install is the accepted intermed
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.
-- [ ] **New gap found while auditing "what's needed for a real iPhone app" (2026-08-17):**
- `companion-app/src/screens/` has not been touched since 2026-08-11 (`git log -1` on that
- path) — every panel fix and feature from the six days of sessions since (the full HIG audit
- rounds, receipt drag/paste upload, trip editing, Ankunftszeit, Inspektion forecast,
- Heckklappenschloss removal, the Tag-theme placeholder fix, all of it) exists **only** in
- `homeassistant/www/audi-dashboard-app.js`, not in the companion app. The two codebases are
- independent implementations of the same product, not a shared component — nothing in this
- repo currently keeps them in sync automatically. Whoever next works on the companion app
- needs to either treat this as a deliberate "port everything since 2026-08-11" task, or
- explicitly decide the divergence is acceptable for now. Not scoped or started.
+- [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 `` 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.
- [ ] 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`
diff --git a/companion-app/README.md b/companion-app/README.md
index ae33d4f..34cc009 100644
--- a/companion-app/README.md
+++ b/companion-app/README.md
@@ -70,7 +70,12 @@ dort niemals Markendateien ablegen.
## Noch offen
-- Capacitor-Hülle und sichere Ablage des Tokens (Keychain/Keystore)
+- Native Hülle selbst: `ios/`/`android/` (von `npx cap add` erzeugt, absichtlich
+ gitignored) existieren in keinem Checkout dauerhaft und müssen auf einem Mac
+ (für iOS zwingend, Xcode) neu erzeugt werden - die sichere Tokenablage
+ (Keychain/Keystore) ist dagegen bereits vollständig angebunden
+ (`src/api/ablageNativ.ts`, von `main.tsx` aktiviert) und wird automatisch
+ aktiv, sobald die Hülle existiert
- QR-Einrichtung als Alternative zum Einfügen des Tokens
- Live-Ansicht der laufenden Fahrt: gebaut, aber über
`src/funktionen.ts` abgeschaltet, bis der FMM003 echte Werte liefert
diff --git a/companion-app/src/api/index.ts b/companion-app/src/api/index.ts
index 0ef1451..dc4386a 100644
--- a/companion-app/src/api/index.ts
+++ b/companion-app/src/api/index.ts
@@ -33,6 +33,21 @@ export interface TankvorgangFelder {
kraftstoff?: string | null;
}
+/** Eingabefelder für Fahrten - Namen wie beim Backend (fahrterkennung.py:
+ audi_dashboard_fahrt_manuell_anlegen/audi_dashboard_fahrt_aktualisieren).
+ Alles außer den Zeitpunkten ist optional: leer bleibt es liegen, bis das
+ Kilometerstand-Screening (§7.2) oder eine spätere Bearbeitung es füllt. */
+export interface FahrtFelder {
+ ts_start?: string;
+ ts_end?: string;
+ art?: "privat" | "arbeitsweg";
+ start_ort?: string | null;
+ ziel_ort?: string | null;
+ odo_start?: number | null;
+ odo_end?: number | null;
+ distanz?: number | null;
+}
+
/** Bündelt die drei Bausteine und bildet die Fachvorgänge ab, die das alte
Dashboard über hass.callService erledigt hat. */
export class DataMetricApi {
@@ -94,15 +109,26 @@ export class DataMetricApi {
}
/** Legt eine Fahrt von Hand an — für Fahrten ohne automatische Erkennung. */
- fahrtAnlegen(tsStart: string, tsEnde: string, art: "privat" | "arbeitsweg"): Promise {
+ fahrtAnlegen(felder: FahrtFelder): Promise {
return this.warteschlange.einreihen(
"pyscript",
"audi_dashboard_fahrt_manuell_anlegen",
- { ts_start: tsStart, ts_end: tsEnde, art },
+ { ...felder },
"Fahrt eintragen",
);
}
+ /** Bearbeitet eine bestehende Fahrt, gleich ob automatisch erkannt oder von
+ Hand angelegt. */
+ fahrtAktualisieren(tripId: string, felder: FahrtFelder): Promise {
+ return this.warteschlange.einreihen(
+ "pyscript",
+ "audi_dashboard_fahrt_aktualisieren",
+ { trip_id: tripId, ...felder },
+ "Fahrt ändern",
+ );
+ }
+
fahrtLoeschen(tripId: string): Promise {
return this.warteschlange.einreihen(
"pyscript",
diff --git a/companion-app/src/daten/profilAdapter.test.ts b/companion-app/src/daten/profilAdapter.test.ts
index 108ba70..e4957e4 100644
--- a/companion-app/src/daten/profilAdapter.test.ts
+++ b/companion-app/src/daten/profilAdapter.test.ts
@@ -19,7 +19,6 @@ function beispielprofil(aenderung: Partial = {}): Profil {
untertitel: "quattro · 331 kW",
kennzeichen: "M AB 1234",
tankvolumen_liter: 58,
- wlan_name: "Audi_MMI",
},
einstellungen: {
uebersichtsbild: "seitenansicht.webp",
diff --git a/companion-app/src/daten/profilAdapter.ts b/companion-app/src/daten/profilAdapter.ts
index 75e0ddb..0f7326e 100644
--- a/companion-app/src/daten/profilAdapter.ts
+++ b/companion-app/src/daten/profilAdapter.ts
@@ -21,7 +21,6 @@ export interface Einstellungen {
nachtBis: string
pauseMin: number
markenname: string
- wlanName: string
backupIntervall: string
letztesBackup: string | null
ausfuehrung: string
@@ -114,7 +113,6 @@ export function profilZuEinstellungen(profil: Profil): Einstellungen {
nachtBis: text(einst, "nacht_bis", "06:00"),
pauseMin: zahl(einst, "fahrten_pausenzeit_min") ?? 15,
markenname: text(einst, "kraftstoffanbieter", "Shell"),
- wlanName: text(fahrzeug, "wlan_name"),
backupIntervall: text(einst, "backup_intervall", "aus"),
letztesBackup: text(einst, "letztes_backup") || null,
ausfuehrung: text(fahrzeug, "ausfuehrung"),
@@ -199,7 +197,6 @@ export function zusammenfuehren(
f["zusatz"] = einstellungen.fahrzeugzusatz
f["untertitel"] = einstellungen.fahrzeuguntertitel
f["kennzeichen"] = einstellungen.kennzeichen
- f["wlan_name"] = einstellungen.wlanName
f["ausfuehrung"] = einstellungen.ausfuehrung
f["tankvolumen_liter"] = einstellungen.tankvolumen
f["fin"] = fahrzeug.fin
diff --git a/companion-app/src/daten/service.test.ts b/companion-app/src/daten/service.test.ts
index 736b940..cadcf13 100644
--- a/companion-app/src/daten/service.test.ts
+++ b/companion-app/src/daten/service.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"
-import { letzterOelwechsel, oelwechselPrognose } from "./service"
+import { inspektionPrognose, letzterOelwechsel, oelwechselPrognose } from "./service"
import type { Fahrzeug } from "./profilAdapter"
function fahrzeug(odo: number, modus = "hersteller"): Fahrzeug {
@@ -92,3 +92,23 @@ describe("oelwechselPrognose", () => {
expect(oelwechselPrognose(fahrzeug(46000), [], jetzt)).toBeNull()
})
})
+
+describe("inspektionPrognose", () => {
+ const jetzt = new Date("2026-08-11T12:00:00")
+
+ it("rechnet mit dem festen Intervall 30.000 km / 24 Monate, nicht dem Ölwechsel-Intervall", () => {
+ // Letzte Inspektion 05.03.2025 bei 25000 km -> Ziel 55000. Bei 46000 km
+ // sind das 9000 km offen, unabhängig vom oel-Feld des Fahrzeugs (15000/12).
+ const p = inspektionPrognose(fahrzeug(46000), BUCH, jetzt)
+ expect(p).not.toBeNull()
+ expect(p!.restKm).toBe(9000)
+ })
+
+ it("ignoriert den Ölwechsel-Eintrag bei der Suche nach der letzten Inspektion", () => {
+ expect(inspektionPrognose(fahrzeug(46000), [BUCH[0]!], jetzt)).toBeNull()
+ })
+
+ it("liefert nichts, wenn nie eine Inspektion eingetragen wurde", () => {
+ expect(inspektionPrognose(fahrzeug(46000), [], jetzt)).toBeNull()
+ })
+})
diff --git a/companion-app/src/daten/service.ts b/companion-app/src/daten/service.ts
index 7f4a965..3500c09 100644
--- a/companion-app/src/daten/service.ts
+++ b/companion-app/src/daten/service.ts
@@ -1,13 +1,14 @@
/**
- * Eigene Ölwechsel-Prognose. Portiert aus `oelwechselPrognose()` im alten
- * Panel.
+ * Eigene Service-Prognosen (Ölwechsel, Inspektion). Portiert aus
+ * `oelwechselPrognose()` im alten Panel; die Inspektion kam am 2026-08-17
+ * hinzu (§ AGENTS.md, "Inspektion: voraussichtliches Datum ergänzen").
*
* Die Rechnung beginnt bewusst neu ab dem letzten tatsächlich durchgeführten
- * Ölwechsel — der allgemeine Langzeitdurchschnitt seit dem allerersten
- * Servicebucheintrag wäre dafür zu träge. Aus km und Tagen seit dem letzten
- * Wechsel ergibt sich die aktuelle Fahrleistung pro Tag; hochgerechnet auf die
- * verbleibenden km bis zum eingestellten Intervall ergibt das ein Datum,
- * gedeckelt auf spätestens das Zeitlimit des Intervalls.
+ * Eintrag dieser Art — der allgemeine Langzeitdurchschnitt seit dem
+ * allerersten Servicebucheintrag wäre dafür zu träge. Aus km und Tagen seit
+ * dem letzten Eintrag ergibt sich die aktuelle Fahrleistung pro Tag;
+ * hochgerechnet auf die verbleibenden km bis zum Intervall ergibt das ein
+ * Datum, gedeckelt auf spätestens das Zeitlimit des Intervalls.
*/
import type { Fahrzeug } from "./profilAdapter"
@@ -36,33 +37,31 @@ function monatePlus(isoDatum: string, monate: number): string {
return d.toISOString().slice(0, 10)
}
-/** Der jüngste Servicebucheintrag, dessen Art auf einen Ölwechsel hindeutet. */
-export function letzterOelwechsel(buch: readonly Buchhaltung[]): Buchhaltung | null {
+/** Der jüngste Servicebucheintrag, dessen Art auf das gesuchte Stichwort
+ passt (z. B. "öl"/"oel" oder "inspektion"). */
+function letzterEintrag(buch: readonly Buchhaltung[], stichwort: RegExp): Buchhaltung | null {
const treffer = buch
- .filter((e) => typeof e.art === "string" && /öl|oel/i.test(e.art) && e.datum && e.km != null)
+ .filter((e) => typeof e.art === "string" && stichwort.test(e.art) && e.datum && e.km != null)
.sort((a, b) => String(b.datum).localeCompare(String(a.datum)))
return treffer[0] ?? null
}
-export function oelwechselPrognose(
+/** Der jüngste Servicebucheintrag, dessen Art auf einen Ölwechsel hindeutet. */
+export function letzterOelwechsel(buch: readonly Buchhaltung[]): Buchhaltung | null {
+ return letzterEintrag(buch, /öl|oel/i)
+}
+
+/** Gemeinsamer Kern für Ölwechsel- und Inspektionsprognose - beide rechnen
+ identisch, nur mit anderem Intervall und anderer Suche im Servicebuch. */
+function servicePrognose(
fahrzeug: Fahrzeug,
- buch: readonly Buchhaltung[],
- jetzt = new Date(),
+ letzter: Buchhaltung | null,
+ intervallKm: number,
+ intervallMonate: number,
+ jetzt: Date,
): Oelprognose | null {
if (!fahrzeug.odoBekannt) return null
- const letzter = letzterOelwechsel(buch)
if (!letzter?.datum || letzter.km == null) return null
-
- const oel = fahrzeug.oel
- const intervallKm =
- oel.modus === "hersteller"
- ? (oel.herstellerKm ?? 0)
- : Math.min(oel.km ?? Infinity, oel.herstellerKm ?? Infinity)
- const intervallMonate =
- oel.modus === "hersteller"
- ? (oel.herstellerMonate ?? 0)
- : Math.min(oel.monate ?? Infinity, oel.herstellerMonate ?? Infinity)
-
if (!Number.isFinite(intervallKm) || intervallKm <= 0) return null
const zielKm = letzter.km + intervallKm
@@ -76,7 +75,7 @@ export function oelwechselPrognose(
const tageSeit = (jetzt.getTime() - new Date(letzter.datum).getTime()) / TAG_MS
const kmSeit = fahrzeug.odo - letzter.km
- // Ohne belastbare Fahrleistung (direkt nach dem Wechsel) gibt es keine
+ // Ohne belastbare Fahrleistung (direkt nach dem Eintrag) gibt es keine
// seriöse Kilometerprognose — dann gilt das Zeitlimit.
if (tageSeit <= 0 || kmSeit <= 0) {
return { datum: kappDatum, restKm, kmProTag: 0, durchZeitlimit: true }
@@ -94,3 +93,30 @@ export function oelwechselPrognose(
durchZeitlimit,
}
}
+
+export function oelwechselPrognose(
+ fahrzeug: Fahrzeug,
+ buch: readonly Buchhaltung[],
+ jetzt = new Date(),
+): Oelprognose | null {
+ const oel = fahrzeug.oel
+ const intervallKm =
+ oel.modus === "hersteller"
+ ? (oel.herstellerKm ?? 0)
+ : Math.min(oel.km ?? Infinity, oel.herstellerKm ?? Infinity)
+ const intervallMonate =
+ oel.modus === "hersteller"
+ ? (oel.herstellerMonate ?? 0)
+ : Math.min(oel.monate ?? Infinity, oel.herstellerMonate ?? Infinity)
+ return servicePrognose(fahrzeug, letzterOelwechsel(buch), intervallKm, intervallMonate, jetzt)
+}
+
+/** Inspektion: feste 30.000 km / 24 Monate, wie auch das Fahrzeug selbst und
+ das alte Panel rechnen - kein einstellbares Intervall wie beim Ölwechsel. */
+export function inspektionPrognose(
+ fahrzeug: Fahrzeug,
+ buch: readonly Buchhaltung[],
+ jetzt = new Date(),
+): Oelprognose | null {
+ return servicePrognose(fahrzeug, letzterEintrag(buch, /inspektion/i), 30000, 24, jetzt)
+}
diff --git a/companion-app/src/screens/Batterie.tsx b/companion-app/src/screens/Batterie.tsx
index d46dbb0..5518454 100644
--- a/companion-app/src/screens/Batterie.tsx
+++ b/companion-app/src/screens/Batterie.tsx
@@ -7,7 +7,7 @@
*
* Wichtig für die Einordnung: Der Ladezustand wird aus der **Tagesminimum**-
* Spannung geschätzt (Ruhespannung kurz nach dem Start, bevor die Lichtmaschine
- * anhebt); Werte über 13,2 V gelten als „während der Fahrt gemessen" und
+ * anhebt); Werte über 12,8 V gelten als „während der Fahrt gemessen" und
* taugen nicht als Ruhewert.
*/
@@ -28,8 +28,9 @@ interface Tageswert {
max_ts?: string
}
-/** Ab hier gilt eine Messung als nicht mehr in Ruhe aufgenommen. */
-const AGM_RUHE_MAX_V = 13.2
+/** Ab hier gilt eine Messung als nicht mehr in Ruhe aufgenommen - alles
+ darüber ist schon Lichtmaschinenspannung, nicht mehr Ruhespannung. */
+const AGM_RUHE_MAX_V = 12.8
/** Spannung → Ladezustand für eine AGM-Batterie in Ruhe. */
const LADEKURVE: [number, number][] = [
diff --git a/companion-app/src/screens/Einstellungen.tsx b/companion-app/src/screens/Einstellungen.tsx
index 31f876e..37c3c48 100644
--- a/companion-app/src/screens/Einstellungen.tsx
+++ b/companion-app/src/screens/Einstellungen.tsx
@@ -102,7 +102,7 @@ export function Einstellungen({
onChange={(e) => aendern({ kennzeichen: e.target.value })}
/>
-
+
aendern({ tankvolumen: Number(e.target.value) || 0 })}
/>
-
- aendern({ wlanName: e.target.value })}
- />
-
diff --git a/companion-app/src/screens/FahrtDetail.tsx b/companion-app/src/screens/FahrtDetail.tsx
index a6a389d..1406a0a 100644
--- a/companion-app/src/screens/FahrtDetail.tsx
+++ b/companion-app/src/screens/FahrtDetail.tsx
@@ -9,16 +9,29 @@
* ist, statt eine glaubwürdig aussehende Erfindung zu zeigen.
*/
-import { Pill, Tile } from "@audi-dash/ui"
+import { useState } from "react"
+import { ActionButton, Feld, Pill, Tile } from "@audi-dash/ui"
+
+import type { Fahrt } from "../api"
import { useDaten } from "../daten/DatenKontext"
import { datum, dauer, de, deOderStrich, uhrzeit } from "../format"
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
+import { FahrtFelderFormular, fahrtFelderAusFahrt, fahrtFelderAuswerten } from "./FahrtFelder"
import { Karte } from "./Karte"
+/** "YYYY-MM-DDTHH:mm" in Ortszeit für ein . */
+function alsDatetimeLocal(iso: string): string {
+ const d = new Date(iso)
+ if (Number.isNaN(d.getTime())) return ""
+ const versatz = d.getTimezoneOffset() * 60_000
+ return new Date(d.getTime() - versatz).toISOString().slice(0, 16)
+}
+
export function FahrtDetail({ id }: { id: string | undefined }) {
const { fahrten } = useDaten()
const fahrt = fahrten.find((f) => f.trip_id === id)
+ const [bearbeitenOffen, setzeBearbeitenOffen] = useState(false)
if (!fahrt) {
return (
@@ -29,21 +42,30 @@ export function FahrtDetail({ id }: { id: string | undefined }) {
)
}
+ if (bearbeitenOffen) {
+ return setzeBearbeitenOffen(false)} />
+ }
+
const hatPositionen = fahrt.start_lat != null && fahrt.start_lon != null
return (
<>
-
- {datum(fahrt.ts_start)} · {uhrzeit(fahrt.ts_start)} bis {uhrzeit(fahrt.ts_end)} Uhr
-
+