companion-app: Datensatz sichern/laden portiert (Item 9 Paritaet)

- "Daten ausgeben" zu "Fahrzeugprofil" umgebaut, gleiche zwei Knoepfe wie
  im Panel: Datensatz sichern/laden, popup mit vier Aktionen
- CSV-Spaltennamen jetzt byte-identisch zum Panel-Export (gemeinsamer
  Backend-Parser, csv_import.py) - vorher eigene, abweichende Kopfzeilen
  ohne Reimport-Moeglichkeit
- neue api.csvImportieren(), Fahrzeugprofil-Import ueber das bestehende
  api.profilSchreiben() (volles Profil, nicht die teilweise
  zusammenfuehrende profilSpeichern()-Bequemlichkeitsfunktion)
- Blinden Fleck geprueft: importStatusLesen()/zustandLesen() lesen per
  REST, nicht aus lokalem Cache - dieselbe Racebedingung wie im Panel kann
  hier strukturell nicht auftreten
- Toten dateiwahl-Scaffolding entfernt (nie verdrahtet)

Beim Bauen echten, vorbestehenden Fehler gefunden: companion-app und Panel
verwenden unterschiedliche Feldnamen fuer Wartungsplan-Eintraege
(betrieb/notiz vs. werkstatt/kosten) - als eigene Aufgabe geflaggt statt
hier mitgefixt.

tsc --noEmit sauber, 146/146 Tests, vite build erfolgreich. Nicht live
getestet (kein laufender companion-app-Dev-Server mit Backend-Zugang in
dieser Sitzung).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 10:31:10 +02:00
parent bfb7dec1e9
commit 2be6b99667
5 changed files with 368 additions and 43 deletions
+51
View File
@@ -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<string, unknown>`), 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<string, unknown>` 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)
+19
View File
@@ -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<CsvImportErgebnis> {
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 ?? {}) };
}
}
+11
View File
@@ -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
@@ -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<string, unknown>
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<void>
/** Nach einem erfolgreichen CSV-Import: Fahrten-/Tankvorgangslisten neu laden. */
beiFertig: () => void
}) {
const [laeuft, setzeLaeuft] = useState<Art | null>(null)
const [ergebnis, setzeErgebnis] = useState<{ art: Art; text: string } | null>(null)
const [fehler, setzeFehler] = useState<string | null>(null)
const eingaben = useRef<Partial<Record<Art, HTMLInputElement | null>>>({})
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 (
<Popup open={offen} onClose={schliessen} variant="form" anchor="center">
<span className="ads-eyebrow">{modus === "sichern" ? "Datensatz sichern" : "Datensatz laden"}</span>
<p className="dm-fussnote">
{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."}
</p>
{ARTEN.map(([art, label]) =>
modus === "sichern" ? (
<div key={art} style={{ marginTop: 12 }}>
<ActionButton
onClick={() => exportieren(art, rohprofil, fahrten, tankvorgaenge, wartungsplan)}
>
{label}
{art === "fahrzeugprofil" ? " sichern" : " als CSV"}
</ActionButton>
</div>
) : (
<div key={art} style={{ marginTop: 12 }}>
<ActionButton
onClick={() => eingaben.current[art]?.click()}
disabled={laeuft !== null}
>
{laeuft === art ? "Lädt …" : `${label}${art === "fahrzeugprofil" ? " laden" : " (CSV) laden"}`}
</ActionButton>
<input
ref={(el) => {
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)
}}
/>
</div>
),
)}
{ergebnis && <p className="dm-importergebnis">{ergebnis.text}</p>}
{fehler && <p className="dm-fussnote dm-fussnote--warnung">{fehler}</p>}
<div style={{ marginTop: 16 }}>
<ActionButton onClick={beiSchliessen} disabled={laeuft !== null}>
Fertig
</ActionButton>
</div>
</Popup>
)
}
+35 -43
View File
@@ -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<string | null>(null)
const dateiwahl = useRef<HTMLInputElement | null>(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()}
/>
<DatensatzPopup
offen={datensatzPopup !== null}
modus={datensatzPopup ?? "sichern"}
beiSchliessen={() => setzeDatensatzPopup(null)}
api={api}
rohprofil={rohprofil}
fahrten={fahrten}
tankvorgaenge={tankvorgaenge}
wartungsplan={(fahrzeug.service["buch"] ?? []) as ServicebuchEintrag[]}
beiProfilGeladen={profilGeladen}
beiFertig={() => void jetztAktualisieren()}
/>
<Tile>
<span className="ads-eyebrow">Sicherung</span>
<Feld label="Automatische Sicherung">
@@ -411,48 +434,19 @@ export function Einstellungen({
</Tile>
<Tile>
<span className="ads-eyebrow">Daten ausgeben</span>
<span className="ads-eyebrow">Fahrzeugprofil</span>
<p className="dm-fussnote">
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.
</p>
<div className="dm-knopfreihe">
<ActionButton
onClick={() =>
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
</ActionButton>
<ActionButton
onClick={() =>
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
<ActionButton onClick={() => setzeDatensatzPopup("sichern")}>
Datensatz sichern
</ActionButton>
<ActionButton onClick={() => setzeDatensatzPopup("laden")}>Datensatz laden</ActionButton>
</div>
</Tile>
@@ -588,8 +582,6 @@ export function Einstellungen({
</div>
</Tile>
<input ref={dateiwahl} type="file" accept="application/json" hidden />
{gespeichert && <p className="dm-erfolg">Gespeichert.</p>}
{entwurf && (
<div className="dm-speicherleiste">