diff --git a/AGENTS.md b/AGENTS.md index 1872893..8bdbbf1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4096,6 +4096,57 @@ bumped `2026.8.28.1` → `.2` (initial popup) → `.3` (the race-condition fix - specifically to see this fix, since the panel JS is cache-busted by manifest version, not by content hash). `audi_ha_test` restarted at each step, confirmed clean via log every time. +## AD. Item 9 ported to companion-app (2026.8.28) + +companion-app's starting layout differed from the panel's: no standalone "Fahrzeugprofil" tile existed at +all (its "Sicherung" tile already bundled profile export together with the server-side backup trigger - +`profilExportieren()` there actually downloads the combined `{profil, fahrten, tankvorgaenge}` bundle, the +companion-app equivalent of the panel's separate, untouched "Backup" tile, not of "Fahrzeugprofil" - so +"Sicherung" was correctly left alone). "Daten ausgeben" only exported Fahrten/Tankvorgänge (no Wartungsplan, +no CSV import at all, no ID columns). Ported by turning "Daten ausgeben" into the same "Fahrzeugprofil" tile ++ "Datensatz sichern"/"laden" popup the panel now has, reaching the same end state as the panel through a +different starting layout - exactly what the parity rule asks for. + +**The CSV column names had to be made byte-identical to the panel's** (`"ID"`, `"Start"`, `"Ende"`, `"km"`, +etc.), not merely similar - the backend CSV parser (`csv_import.py`) is shared by both frontends and looks +columns up by exact name. companion-app's own prior CSV export used different header text entirely +("Beginn"/"Strecke km"/"Dauer s") - harmless before, since nothing ever read it back, but would have broken +cross-frontend round-tripping (export from the panel, import into companion-app, or vice versa) had it been +left as-is under a shared backend parser. + +**Found a second real, pre-existing bug while doing this**: companion-app's `ServicebuchEintrag` type +(`Service.tsx`) declares `betrieb`/`notiz`, but the actual backend list the panel writes to +(`profil["service"]["buch"]`) uses `werkstatt`/`kosten`/`arbeiten` - no translation layer exists anywhere +(`profilAdapter.ts` passes `service` through as an untyped `Record`), so a Wartungsplan +entry created in one codebase genuinely doesn't display correctly in the other (shows "Betrieb: —", no +cost). Out of scope for this feature to fix properly - `DatensatzPopup.tsx`'s Wartungsplan CSV export reads +the real field names via a documented `as Record` cast (with an inline comment explaining +why) rather than trusting the local, apparently-wrong type. Flagged as a separate background task +(`task_7105e44c`) for a dedicated fix - decide the canonical field names (almost certainly the panel's, the +older and more established convention), fix `ServicebuchEintrag` and `Service.tsx`'s read/write code, and +simplify the CSV export's workaround cast once the type itself is correct. + +New `src/screens/DatensatzPopup.tsx` (own file, mirrors `HistorienImport.tsx`'s shape/idiom: `Popup +variant="form"`, own open/close/error/result state, an `aktiv` ref guarding against a state update after +unmount). New `api.csvImportieren(art, inhalt)` (`api/index.ts`) - not queued, same reasoning as +`historieImportieren()` (a deliberate one-off action, not a form field that should tolerate being offline). +Fahrzeugprofil import reuses the existing `api.profilSchreiben(profil)` directly (NOT the `DatenKontext` +convenience `profilSpeichern()`, which only merges partial `einstellungen`/`fahrzeug` fields - a loaded file +needs to replace the whole profile, matching the panel's `profil_schreiben` semantics exactly), followed by +`neuLaden()` to refresh derived state. `importStatusLesen()`/`zustandLesen()` fetch over REST, not a locally +cached reactive object - so companion-app was never exposed to the race-condition class of bug the panel +needed a dedicated fix for (see section AC) - confirmed structurally, not just assumed. + +Also removed: a dead `dateiwahl` file-input ref in `Einstellungen.tsx` (declared, rendered, never wired to +an `onChange` or `.click()` anywhere) - pre-existing, unrelated scaffolding, but sitting directly next to +the new, functionally-equivalent file-input mechanism this change adds made it actively confusing to leave, +so removed rather than merely noted. + +Verified: `tsc --noEmit` clean, full suite green at 146/146 (including the "every page renders with sample +data" smoke test, which covers `Einstellungen.tsx`), `vite build` succeeds. **Not live-verified** - no +running companion-app session with backend auth this round, the same structural limitation noted repeatedly +elsewhere in this file for companion-app-only changes. + --- ## Working conventions (observed — keep them) diff --git a/companion-app/src/api/index.ts b/companion-app/src/api/index.ts index a592bfc..0123f91 100644 --- a/companion-app/src/api/index.ts +++ b/companion-app/src/api/index.ts @@ -16,6 +16,7 @@ export * from "./ablageNativ.ts"; import { DIENST_DOMAIN, ENTITAETEN } from "./types.ts"; import type { AppVersionAngabe, + CsvImportErgebnis, Fahrt, Fahrzeugstatus, ImportErgebnis, @@ -304,4 +305,22 @@ export class DataMetricApi { ); return { zustand: zustand.state, daten: zustand.attributes?.daten ?? {} }; } + + /* --------------------------------------------------- CSV-Import + Wie historieImportieren() bewusst NICHT über die Warteschlange: eine + bewusste, einmalige Aktion, deren Ergebnis der eigentliche Zweck des + Aufrufs ist, kein Formularfeld, das man auch offline abschicken würde. + Anders als dort aber synchron im Backend (csv_import.py) - der + Dienstaufruf kehrt erst zurück, wenn sensor.audi_dashboard_import_status + den neuen Stand schon trägt, keine Wartezeit nötig. */ + async csvImportieren( + art: "fahrten" | "tanken" | "service", + inhalt: string, + ): Promise { + await this.rest.dienstAufrufen(DIENST_DOMAIN, "csv_importieren", { art, inhalt }); + const zustand = await this.rest.zustandLesen<{ daten: CsvImportErgebnis }>( + ENTITAETEN.importStatus, + ); + return { art, ...(zustand.attributes?.daten ?? {}) }; + } } diff --git a/companion-app/src/api/types.ts b/companion-app/src/api/types.ts index c521b90..7eeccb2 100644 --- a/companion-app/src/api/types.ts +++ b/companion-app/src/api/types.ts @@ -163,6 +163,17 @@ export interface ImportErgebnis { meldung?: string; } +/** Ergebnis eines CSV-Imports (custom_components/audi_dashboard/csv_import.py) - + dieselbe Entität wie ImportErgebnis (sensor.audi_dashboard_import_status), + aber eine andere Kennzahlform: Zeilenabgleich statt Fahrten/Tankvorgänge/ + Batterie getrennt. */ +export interface CsvImportErgebnis { + art?: string; + angelegt?: number; + aktualisiert?: number; + uebersprungen?: number; +} + /** Das OTA-Bündel, das die Integration mit ausliefert (siehe custom_components/audi_dashboard/koordinator.py `_buendel_lesen()` und companion-app/scripts/ota-paket.ps1). `null`, solange keine diff --git a/companion-app/src/screens/DatensatzPopup.tsx b/companion-app/src/screens/DatensatzPopup.tsx new file mode 100644 index 0000000..0d3c9dc --- /dev/null +++ b/companion-app/src/screens/DatensatzPopup.tsx @@ -0,0 +1,252 @@ +/** + * "Datensatz sichern"/"Datensatz laden" — Einstellungen/Fahrzeugprofil. + * Vorlage: `vDatensatzPopup()` im Panel (audi-dashboard-app.js). + * + * "Sichern" löst vier sofortige, rein clientseitige Exporte aus + * (Fahrzeugprofil als JSON, Fahrten/Tankvorgänge/Wartungsplan als CSV). + * "Laden" spiegelt dieselben vier Dateien als Dateiauswahl - Fahrzeugprofil + * über den bestehenden profil_schreiben-Dienst (api.profilSchreiben()), + * die drei CSV-Datensätze über den neuen csv_importieren-Dienst + * (api.csvImportieren(), custom_components/audi_dashboard/csv_import.py): + * eine Zeile mit bekannter ID aktualisiert nur ihre eigenen Spalten, alles + * andere am Datensatz bleibt unangetastet - siehe dessen Moduldocstring für + * die Begründung. + * + * Die CSV-Spaltennamen sind absichtlich BYTE-IDENTISCH zum Panel-Export + * ("ID","Start","Ende","km","Art","Status" usw.) - derselbe Backend-Parser + * bedient beide Oberflächen, eine hier exportierte Datei muss sich also auch + * im Panel importieren lassen und umgekehrt. Vorher hatte diese Seite eigene, + * abweichende Spaltennamen ("Beginn"/"Strecke km"/"Dauer s") - die gab es + * nur zum Lesen in einer Tabelle, nie zum Reimport, ein Rundlauf wäre also + * gescheitert. + */ + +import { useEffect, useRef, useState } from "react" + +import { ActionButton, Popup } from "@audi-dash/ui" + +import type { CsvImportErgebnis, DataMetricApi, Fahrt, Profil, Tankvorgang } from "../api" +import { de, eur } from "../format" +import { csvHerunterladen } from "./csv" +import type { ServicebuchEintrag } from "./Service" + +type Art = "fahrzeugprofil" | "fahrten" | "tanken" | "service" + +const ARTEN: [Art, string][] = [ + ["fahrzeugprofil", "Fahrzeugprofil"], + ["fahrten", "Fahrten"], + ["tanken", "Tankvorgänge"], + ["service", "Wartungsplan"], +] + +function exportieren( + art: Art, + rohprofil: Profil | null, + fahrten: readonly Fahrt[], + tankvorgaenge: readonly Tankvorgang[], + wartungsplan: readonly ServicebuchEintrag[], +) { + if (art === "fahrzeugprofil") { + const blob = new Blob([JSON.stringify(rohprofil, null, 2)], { type: "application/json" }) + const url = URL.createObjectURL(blob) + const verweis = document.createElement("a") + verweis.href = url + verweis.download = "fahrzeugprofil.json" + verweis.click() + URL.revokeObjectURL(url) + return + } + if (art === "fahrten") { + csvHerunterladen( + "fahrten", + ["ID", "Start", "Ende", "km", "Art", "Status"], + fahrten.map((f) => [f.trip_id, f.ts_start, f.ts_end, f.distance_km ?? "", f.art, f.status]), + ) + return + } + if (art === "tanken") { + csvHerunterladen( + "tankvorgaenge", + ["ID", "Zeitpunkt", "Station", "Liter", "€/l", "Kosten €"], + tankvorgaenge.map((t) => [ + t.tank_id, + t.ts, + t.station_name ?? "", + t.liters ?? "", + t.liters && t.fuel_total_eur != null ? de(t.fuel_total_eur / t.liters, 2) : "", + t.fuel_total_eur != null ? eur(t.fuel_total_eur) : "", + ]), + ) + return + } + // ACHTUNG: liest werkstatt/kosten, nicht ServicebuchEintrag.betrieb/notiz - + // das ist absichtlich. Der tatsächlich gespeicherte Datensatz + // (profil["service"]["buch"], vom Backend/Panel geschrieben) trägt + // werkstatt/kosten/arbeiten; ServicebuchEintrag hier im Frontend nennt + // sie betrieb/notiz und wird nirgends dazwischen übersetzt (Service.tsx + // liest fahrzeug.service["buch"] ungemappt durch) - ein + // Schema-Auseinanderlaufen zwischen den beiden Oberflächen, das beim Bau + // dieses CSV-Exports auffiel, aber ein eigenes Thema ist (siehe AGENTS.md). + // Der Export muss die echten Feldnamen lesen, sonst zeigt jede exportierte + // Zeile eine leere Werkstatt/Kosten-Spalte. + csvHerunterladen( + "wartungsplan", + ["Datum", "km", "Art", "Werkstatt", "Kosten €"], + wartungsplan.map((e) => { + const roh = e as Record + const werkstatt = typeof roh.werkstatt === "string" ? roh.werkstatt : "" + const kosten = typeof roh.kosten === "number" ? roh.kosten : null + return [e.datum ?? "", e.km != null ? de(e.km) : "", e.art ?? "", werkstatt, kosten != null ? eur(kosten) : ""] + }), + ) +} + +function ergebnisText(art: Art, e: CsvImportErgebnis): string { + if (art === "fahrzeugprofil") return "Fahrzeugprofil geladen." + const teile: string[] = [] + if (e.angelegt) teile.push(`${de(e.angelegt)} neu angelegt`) + if (e.aktualisiert) teile.push(`${de(e.aktualisiert)} aktualisiert`) + if (e.uebersprungen) teile.push(`${de(e.uebersprungen)} übersprungen`) + return teile.length ? teile.join(" · ") : "Keine gültigen Zeilen in der Datei gefunden." +} + +export function DatensatzPopup({ + offen, + modus, + beiSchliessen, + api, + rohprofil, + fahrten, + tankvorgaenge, + wartungsplan, + beiProfilGeladen, + beiFertig, +}: { + offen: boolean + modus: "sichern" | "laden" + beiSchliessen: () => void + api: DataMetricApi + rohprofil: Profil | null + fahrten: readonly Fahrt[] + tankvorgaenge: readonly Tankvorgang[] + wartungsplan: readonly ServicebuchEintrag[] + /** Nach einem geladenen Fahrzeugprofil: den neuen Stand übernehmen. */ + beiProfilGeladen: (profil: Profil) => Promise + /** Nach einem erfolgreichen CSV-Import: Fahrten-/Tankvorgangslisten neu laden. */ + beiFertig: () => void +}) { + const [laeuft, setzeLaeuft] = useState(null) + const [ergebnis, setzeErgebnis] = useState<{ art: Art; text: string } | null>(null) + const [fehler, setzeFehler] = useState(null) + const eingaben = useRef>>({}) + const aktiv = useRef(true) + useEffect(() => { + aktiv.current = true + return () => { + aktiv.current = false + } + }, []) + + useEffect(() => { + if (offen) { + setzeErgebnis(null) + setzeFehler(null) + setzeLaeuft(null) + } + }, [offen]) + + const schliessen = () => { + if (laeuft) return + beiSchliessen() + } + + const dateiGewaehlt = async (art: Art, datei: File) => { + setzeLaeuft(art) + setzeErgebnis(null) + setzeFehler(null) + let inhalt: string + try { + inhalt = await datei.text() + } catch { + if (!aktiv.current) return + setzeLaeuft(null) + setzeFehler("Die Datei ließ sich nicht lesen.") + return + } + try { + if (art === "fahrzeugprofil") { + const profil = JSON.parse(inhalt) as Profil + await beiProfilGeladen(profil) + if (!aktiv.current) return + setzeErgebnis({ art, text: ergebnisText(art, {}) }) + } else { + const e = await api.csvImportieren(art, inhalt) + if (!aktiv.current) return + setzeErgebnis({ art, text: ergebnisText(art, e) }) + beiFertig() + } + } catch (err) { + if (!aktiv.current) return + setzeFehler( + art === "fahrzeugprofil" + ? `Die Datei ließ sich nicht lesen: ${err instanceof Error ? err.message : "unbekannter Fehler"}` + : `Der Import ist fehlgeschlagen: ${err instanceof Error ? err.message : "unbekannter Fehler"}`, + ) + } finally { + if (aktiv.current) setzeLaeuft(null) + } + } + + return ( + + {modus === "sichern" ? "Datensatz sichern" : "Datensatz laden"} +

+ {modus === "sichern" + ? "Semikolon als Trennzeichen und deutsches Zahlenformat, damit die Dateien ohne Umweg in Tabellenprogrammen aufgehen." + : "Eine zuvor gesicherte, ggf. bearbeitete Datei wieder einspielen — eine Zeile mit bekannter ID aktualisiert nur ihre eigenen Spalten, alles andere bleibt unangetastet."} +

+ {ARTEN.map(([art, label]) => + modus === "sichern" ? ( +
+ exportieren(art, rohprofil, fahrten, tankvorgaenge, wartungsplan)} + > + {label} + {art === "fahrzeugprofil" ? " sichern" : " als CSV"} + +
+ ) : ( +
+ eingaben.current[art]?.click()} + disabled={laeuft !== null} + > + {laeuft === art ? "Lädt …" : `${label}${art === "fahrzeugprofil" ? " laden" : " (CSV) laden"}`} + + { + eingaben.current[art] = el + }} + type="file" + accept={art === "fahrzeugprofil" ? "application/json" : "text/csv"} + hidden + onChange={(e) => { + const datei = e.target.files?.[0] + e.target.value = "" + if (datei) void dateiGewaehlt(art, datei) + }} + /> +
+ ), + )} + {ergebnis &&

{ergebnis.text}

} + {fehler &&

{fehler}

} +
+ + Fertig + +
+
+ ) +} + diff --git a/companion-app/src/screens/Einstellungen.tsx b/companion-app/src/screens/Einstellungen.tsx index c1210a3..d43543c 100644 --- a/companion-app/src/screens/Einstellungen.tsx +++ b/companion-app/src/screens/Einstellungen.tsx @@ -4,11 +4,12 @@ * Datenausgabe und Zugang. */ -import { useEffect, useRef, useState } from "react" +import { useEffect, useState } from "react" import { ActionButton, Feld, Seg, Switch, Tile } from "@audi-dash/ui" import { DIENST_DOMAIN, zugangLesen, zugangVerwerfen } from "../api" +import type { Profil } from "../api" import { eigeneVersion } from "../daten/appVersion" import { useDaten } from "../daten/DatenKontext" import { OtaFehler, buendelAnwenden, buendelPasst, otaMoeglich } from "../daten/ota" @@ -18,8 +19,9 @@ import { useTheme } from "../theme" import { Wertzeile, Werteliste, bestaetigen } from "./bausteine" import { Bild } from "./Bild" import { BILDPLAETZE } from "./bilder" -import { csvHerunterladen } from "./csv" +import { DatensatzPopup } from "./DatensatzPopup" import { HistorienImport } from "./HistorienImport" +import type { ServicebuchEintrag } from "./Service" import { zugangVergessen } from "./zugang" export function Einstellungen({ @@ -56,7 +58,7 @@ export function Einstellungen({ const [updateInstallierenLaeuft, setzeUpdateInstallierenLaeuft] = useState(false) const [updateNeustartLaeuft, setzeUpdateNeustartLaeuft] = useState(false) const [updateAktionFehler, setzeUpdateAktionFehler] = useState(null) - const dateiwahl = useRef(null) + const [datensatzPopup, setzeDatensatzPopup] = useState<"sichern" | "laden" | null>(null) const otaUpdateVerfuegbar = otaMoeglich() && buendelPasst(otaBuendel, eigeneVersion()) @@ -179,6 +181,14 @@ export function Einstellungen({ beiAbmeldung() } + // Anders als profilSpeichern() (Kontext) - das nur einstellungen/fahrzeug + // teilweise zusammenführt - schreibt eine geladene Datei das GANZE Profil, + // deckungsgleich mit profil_schreiben im Panel. + const profilGeladen = async (profil: Profil) => { + await api.profilSchreiben(profil) + await neuLaden() + } + const profilExportieren = () => { const inhalt = JSON.stringify( { profil: rohprofil, fahrten, tankvorgaenge, erzeugt: new Date().toISOString() }, @@ -376,6 +386,19 @@ export function Einstellungen({ beiFertig={() => void jetztAktualisieren()} /> + setzeDatensatzPopup(null)} + api={api} + rohprofil={rohprofil} + fahrten={fahrten} + tankvorgaenge={tankvorgaenge} + wartungsplan={(fahrzeug.service["buch"] ?? []) as ServicebuchEintrag[]} + beiProfilGeladen={profilGeladen} + beiFertig={() => void jetztAktualisieren()} + /> + Sicherung @@ -411,48 +434,19 @@ export function Einstellungen({ - Daten ausgeben + Fahrzeugprofil

- Als CSV mit Semikolon und deutschem Zahlenformat — so öffnet Excel die Datei ohne - Nachfragen. + Alles Fahrzeugspezifische liegt in einer einzigen Datei: Stammdaten, technische Daten, + Ausstattung, Reifen, Versicherung, Steuer und Werkstatt. Fahrten, Tankvorgänge und der + Wartungsplan lassen sich einzeln als CSV sichern und bearbeitet wieder einspielen — eine + Zeile mit bekannter ID aktualisiert nur ihre eigenen Spalten, alles andere bleibt + unangetastet.

- - csvHerunterladen( - "fahrten", - ["Beginn", "Ende", "Strecke km", "Dauer s", "Art", "Status"], - fahrten.map((f) => [ - f.ts_start, - f.ts_end, - f.distance_km ?? "", - f.duration_s, - f.art, - f.status, - ]), - ) - } - > - Fahrten - - - csvHerunterladen( - "tankvorgaenge", - ["Zeitpunkt", "Liter", "Betrag", "Preis je Liter", "Tankstelle", "Kilometerstand"], - tankvorgaenge.map((t) => [ - t.ts, - t.liters ?? "", - t.fuel_total_eur ?? "", - t.price_per_l ?? "", - t.station_name ?? "", - t.odometer_km ?? "", - ]), - ) - } - > - Tankvorgänge + setzeDatensatzPopup("sichern")}> + Datensatz sichern + setzeDatensatzPopup("laden")}>Datensatz laden
@@ -588,8 +582,6 @@ export function Einstellungen({ - - {gespeichert &&

Gespeichert.

} {entwurf && (