Batteriespannungs-Messwertliste, Wisch-Löschen, Reifensatz-Archiv, CI-Icons

Fünf vom Nutzer angeforderte Erweiterungen in einer Runde:

- Batteriespannungs-Kachel bekommt eine Messwertliste (Datum, Uhrzeit,
  Spannung, optional Außentemperatur über den neuen AUSSENTEMP_SENSOR),
  erreichbar über ein neues list-s-Symbol in der Diagramm-Kachel. Werte
  über AGM_RUHE_MAX_V (12,8 V) sind keine Ruhespannung, sondern
  Lichtmaschinenspannung - die Zeile markiert das jetzt mit
  "Generatorspannung" statt es unkommentiert als Messwert auszugeben.
- Jede Zeile der neuen Liste wischbar zum Löschen (neuer Dienst
  batterieverlauf_loeschen), über dieselbe Wisch-Mechanik wie Fahrten/
  Tankvorgänge in beiden Oberflächen.
- Batteriespannungs-Diagramm im Panel war auf großen Bildschirmen (≥860px,
  Spaltenlayout) stark in die Breite gezogen (preserveAspectRatio="none"
  bei fester Höhe) - gedeckelt wie die dort bereits vorhandenen Popups.
- Statistik-Tab-Symbol in beiden Oberflächen durch das Audi-CI-Symbol
  polls-s ersetzt.
- Neue Möglichkeit, einen Reifensatz zu archivieren ("Neue Räder
  anlegen"): der aktuelle Stand (km, Marke, Modell, DOT, Maße, Solldruck,
  Kommentar) wandert in ein Archiv, der laufende Satz beginnt bei 0 km neu.
  Archiv als aufklappbare Übersicht mit voller Bearbeitungsmöglichkeit,
  über drei neue, bewusst nicht über profilSchreiben laufende Dienste
  (reifen_archivieren/-aktualisieren/-loeschen) - aus demselben Grund wie
  reifen_wechseln: kein im Browser gehaltener Stand darf einen
  zwischenzeitlich fortgeschriebenen km-Wert überschreiben.

Live im Testcontainer geprüft (erstmals über :18123 statt :8123 erreichbar)
und dabei zwei echte Layout-Fehler gefunden, die kein Compile-/Testlauf
sehen konnte: die Archiv-Eingabefelder waren durch ein fälschlich
verwendetes .mitEinheit (feste 96px-Breite, eigentlich für Zahl+Einheit
gedacht) abgeschnitten ("Continenta" statt "Continental"), und die
Kilometerzahl in der eingeklappten Archiv-Zeile war klein an das Datum
gequetscht statt wie der Satzname lesbar. Beides behoben, live erneut
bestätigt.

Backend: py_compile clean, services.yaml ergänzt. Panel: node --check
clean. companion-app: tsc/Testsuite (146/146)/Build/OTA-Bündel alle grün.
Manifest 2026.8.25.2 → .8, jeder Neustart im Testcontainer sauber.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-08-27 11:44:24 +02:00
co-authored by Claude Sonnet 5
parent 70253e83a5
commit 17149937a6
23 changed files with 1025 additions and 31 deletions
+14 -5
View File
@@ -13,11 +13,13 @@
import { useEffect, useState } from "react"
import { Tile } from "@audi-dash/ui"
import { IconButton, Tile } from "@audi-dash/ui"
import { ENTITAETEN } from "../api"
import { useDaten } from "../daten/DatenKontext"
import { datum, de } from "../format"
import type { SeitenName } from "../navigation"
import { SymbolListe } from "../symbole"
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
interface Tageswert {
@@ -29,8 +31,10 @@ interface Tageswert {
}
/** 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
darüber ist schon Lichtmaschinenspannung, nicht mehr Ruhespannung. Auch
von BatterieListe.tsx verwendet, für die "Generatorspannung"-Kennzeichnung
dort - eine einzige Schwelle für beide Ansichten. */
export const AGM_RUHE_MAX_V = 12.8
/** Spannung → Ladezustand für eine AGM-Batterie in Ruhe. */
const LADEKURVE: [number, number][] = [
@@ -49,7 +53,7 @@ function ladezustand(spannung: number): number | null {
return 0
}
export function Batterie() {
export function Batterie({ geheZu }: { geheZu: (name: SeitenName) => void }) {
const { api, fahrzeug } = useDaten()
const [verlauf, setzeVerlauf] = useState<Tageswert[] | null>(null)
const [fehler, setzeFehler] = useState(false)
@@ -97,7 +101,12 @@ export function Batterie() {
return (
<>
<Tile>
<span className="ads-eyebrow">Ruhespannung</span>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span className="ads-eyebrow">Ruhespannung</span>
<IconButton aria-label="Messwertliste" onClick={() => geheZu("battliste")}>
<SymbolListe groesse={18} />
</IconButton>
</div>
<div className="dm-detail__kopf">
<div className="dm-detail__zahl">
{letzter.min != null ? de(letzter.min, 2) : "—"}
+103
View File
@@ -0,0 +1,103 @@
/**
* Batteriespannungs-Messwertliste — ein Eintrag pro Tag (dieselben Daten wie
* das Diagramm in Batterie.tsx), aber als Liste: Datum, Uhrzeit, Spannung,
* Außentemperatur. Erreichbar über das Listen-Symbol oben rechts im
* Diagramm.
*
* Jede Zeile lässt sich wie Fahrten/Tankvorgänge nach links wegwischen, um
* sie zu löschen (Zeilenmenue - Wischgeste plus sichtbares Menü, siehe dort).
*
* Liegt der Tagesminimalwert über AGM_RUHE_MAX_V (Batterie.tsx), wurde er nie
* in Ruhe gemessen (das Fahrzeug stand an diesem Tag nie lange genug still) -
* die Zeile zeigt dann "Generatorspannung" statt so zu tun, als sei es ein
* Ruhewert.
*/
import { useEffect, useState } from "react"
import { Pill, Tile } from "@audi-dash/ui"
import { ENTITAETEN } from "../api"
import { useDaten } from "../daten/DatenKontext"
import { datum, datumZeit, de } from "../format"
import { Leerzustand } from "./bausteine"
import { AGM_RUHE_MAX_V } from "./Batterie"
import { Zeilenmenue } from "./Zeilenmenue"
interface Tageswert {
datum: string
min: number | null
max: number | null
min_ts?: string
min_temp_c?: number | null
}
export function BatterieListe() {
const { api } = useDaten()
const [verlauf, setzeVerlauf] = useState<Tageswert[] | null>(null)
const [fehler, setzeFehler] = useState(false)
const neuLaden = () => {
void api.rest
.datenLesen<Tageswert[]>(ENTITAETEN.batterieverlauf)
.then((daten) => setzeVerlauf(Array.isArray(daten) ? daten : []))
.catch(() => setzeFehler(true))
}
useEffect(neuLaden, [api])
if (fehler) {
return (
<Leerzustand
titel="Liste nicht abrufbar"
text="Der Server hat die Messwertliste nicht geliefert. Beim nächsten Aufruf klappt es vielleicht wieder."
/>
)
}
if (!verlauf) return <div className="dm-laden">Liste wird geladen </div>
const messwerte = verlauf.filter((t) => t.min != null).slice().reverse()
if (messwerte.length === 0) {
return (
<Leerzustand
titel="Keine Messwerte"
text="Für dieses Fahrzeug liefert die Anbindung derzeit keinen Spannungssensor."
/>
)
}
const loeschen = async (eintrag: Tageswert) => {
await api.batterieverlaufEintragLoeschen(eintrag.datum)
neuLaden()
}
return (
<Tile>
{messwerte.map((t) => (
<Zeilenmenue
key={t.datum}
loeschFrage={`Messwert vom ${datum(t.datum)} löschen?`}
beiLoeschen={() => void loeschen(t)}
kinder={
<div className="dm-listenzeile">
<span className="dm-listenzeile__haupt">
<span className="dm-listenzeile__titel">
{de(t.min!, 2)} V
<span className="dm-listenzeile__neben">
{t.min_temp_c != null ? `${de(t.min_temp_c, 1)} °C` : "—"}
</span>
</span>
<span className="dm-listenzeile__unter">
{t.min_ts ? datumZeit(t.min_ts) : datum(t.datum)}
</span>
</span>
{t.min! > AGM_RUHE_MAX_V && <Pill>Generatorspannung</Pill>}
</div>
}
/>
))}
</Tile>
)
}
+208 -2
View File
@@ -9,12 +9,13 @@
import { useState } from "react"
import { ActionButton, Feld, Seg, Tile } from "@audi-dash/ui"
import { Accordion, ActionButton, Feld, IconButton, Seg, Tile } from "@audi-dash/ui"
import { DIENST_DOMAIN } from "../api"
import { useDaten } from "../daten/DatenKontext"
import type { Reifensatz } from "../daten/profilAdapter"
import type { Reifensatz, ReifenArchivEintrag } from "../daten/profilAdapter"
import { datum, de, deOderStrich } from "../format"
import { SymbolPaket } from "../symbole"
import { Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { Bild } from "./Bild"
import { kalenderDateiLaden } from "./kalender"
@@ -45,6 +46,19 @@ export function Reifen() {
}
}
const archivieren = async () => {
if (!bestaetigen("Neue Räder anlegen?")) return
setzeLaeuft(true)
try {
// Eigener Dienst wie reifen_wechseln: archiviert den bisherigen Stand
// und setzt den laufenden Satz zurück (km 0, Marke/Modell/DOT/Kommentar
// leer - siehe reifen.py archivieren()).
await api.reifenArchivieren(anzeige === "Winter" ? "winter" : "sommer")
} finally {
setzeLaeuft(false)
}
}
const wechseldatumSetzen = async (neuesDatum: string) => {
await profilSpeichern({
fahrzeug: {
@@ -125,6 +139,11 @@ export function Reifen() {
beschreibung={`${anzeige}rad`}
name={`${anzeige}rad`}
/>
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: -6 }}>
<IconButton aria-label="Neue Räder anlegen" onClick={() => void archivieren()} disabled={laeuft}>
<SymbolPaket groesse={18} />
</IconButton>
</div>
<Werteliste
kinder={
<>
@@ -153,6 +172,193 @@ export function Reifen() {
rechnet sie immer dem gerade montierten Satz zu.
</p>
</Tile>
{fahrzeug.reifen.archiv.length > 0 && (
<Tile>
<span className="ads-eyebrow">Archiv</span>
{fahrzeug.reifen.archiv.map((eintrag) => (
<ArchivZeile key={eintrag.id} eintrag={eintrag} />
))}
</Tile>
)}
</>
)
}
function ArchivZeile({ eintrag }: { eintrag: ReifenArchivEintrag }) {
const { api } = useDaten()
const [bearbeiten, setzeBearbeiten] = useState(false)
const [laeuft, setzeLaeuft] = useState(false)
const [form, setzeForm] = useState(() => ({
ersetzt_am: eintrag.ersetzt_am,
km: String(eintrag.km ?? ""),
marke: eintrag.marke ?? "",
modell: eintrag.modell ?? "",
dot: eintrag.dot ?? "",
mass: eintrag.mass ?? "",
druck_vorne: eintrag.druck_vorne ?? "",
druck_hinten: eintrag.druck_hinten ?? "",
kommentar: eintrag.kommentar ?? "",
}))
const speichern = async () => {
setzeLaeuft(true)
try {
const kmZahl = Number(form.km.replace(",", "."))
await api.reifenArchivAktualisieren(eintrag.id, {
ersetzt_am: form.ersetzt_am,
km: Number.isFinite(kmZahl) ? kmZahl : null,
marke: form.marke || null,
modell: form.modell || null,
dot: form.dot || null,
mass: form.mass || null,
druck_vorne: form.druck_vorne || null,
druck_hinten: form.druck_hinten || null,
kommentar: form.kommentar || null,
})
setzeBearbeiten(false)
} finally {
setzeLaeuft(false)
}
}
const loeschen = async () => {
if (!bestaetigen("Diesen archivierten Reifensatz löschen?")) return
setzeLaeuft(true)
try {
await api.reifenArchivLoeschen(eintrag.id)
} finally {
setzeLaeuft(false)
}
}
return (
<Accordion
title={eintrag.satz === "winter" ? "Winterräder" : "Sommerräder"}
summary={
// km in derselben Größe/Farbe wie der Titel (nicht als angehängte
// .ads-acc-h__v-Kleinschrift ans Datum gequetscht - live beobachtet,
// dass das schwer lesbar war), Datum als eigene, kleinere Zeile
// darunter. Parität zum Panel (dieselbe Umstellung dort).
<span style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 3 }}>
<span style={{ color: "var(--fg)", fontSize: 15 }}>{deOderStrich(eintrag.km)} km</span>
<small>Ersetzt am {datum(eintrag.ersetzt_am)}</small>
</span>
}
>
{bearbeiten ? (
<>
<Feld label="Ersetzt am">
<input
className="dm-eingabe"
type="date"
value={form.ersetzt_am}
onChange={(e) => setzeForm({ ...form, ersetzt_am: e.target.value })}
/>
</Feld>
<Feld label="Gefahrene Strecke" unit="km">
<input
className="dm-eingabe"
inputMode="decimal"
value={form.km}
onChange={(e) => setzeForm({ ...form, km: e.target.value })}
/>
</Feld>
<Feld label="Marke">
<input
className="dm-eingabe"
value={form.marke}
onChange={(e) => setzeForm({ ...form, marke: e.target.value })}
/>
</Feld>
<Feld label="Modell">
<input
className="dm-eingabe"
value={form.modell}
onChange={(e) => setzeForm({ ...form, modell: e.target.value })}
/>
</Feld>
<Feld label="DOT">
<input
className="dm-eingabe"
value={form.dot}
onChange={(e) => setzeForm({ ...form, dot: e.target.value })}
/>
</Feld>
<Feld label="Größe">
<input
className="dm-eingabe"
value={form.mass}
onChange={(e) => setzeForm({ ...form, mass: e.target.value })}
/>
</Feld>
<Feld label="Luftdruck vorne" unit="bar">
<input
className="dm-eingabe"
value={form.druck_vorne}
onChange={(e) => setzeForm({ ...form, druck_vorne: e.target.value })}
/>
</Feld>
<Feld label="Luftdruck hinten" unit="bar">
<input
className="dm-eingabe"
value={form.druck_hinten}
onChange={(e) => setzeForm({ ...form, druck_hinten: e.target.value })}
/>
</Feld>
{/* Kein <Feld> hier (Label und Eingabe teilen sich sonst eine Zeile,
die Eingabe bleibt auf ihrer intrinsischen Breite - für eine
Notiz wie "Profiltiefe 5,1 mm bei der letzten Kontrolle" zu
wenig Platz, live beobachtet). Eingabe über die volle Breite,
Label darüber - Parität zum Panel (.feld--breit). */}
<div className="ads-feld" style={{ display: "block", borderBottom: 0 }}>
<label style={{ display: "block", marginBottom: 8 }}>Notiz</label>
<input
className="dm-eingabe"
style={{ width: "100%", textAlign: "left" }}
value={form.kommentar}
onChange={(e) => setzeForm({ ...form, kommentar: e.target.value })}
/>
</div>
<div style={{ display: "flex", gap: 10, marginTop: 14 }}>
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
{laeuft ? "Speichere …" : "Speichern"}
</ActionButton>
<ActionButton onClick={() => setzeBearbeiten(false)} disabled={laeuft}>
Abbrechen
</ActionButton>
</div>
<div style={{ marginTop: 10 }}>
<ActionButton variant="destructive" onClick={() => void loeschen()} disabled={laeuft}>
Löschen
</ActionButton>
</div>
</>
) : (
<>
<Werteliste
kinder={
<>
<Wertzeile label="Größe" wert={eintrag.mass || "—"} />
<Wertzeile
label="Marke und Modell"
wert={[eintrag.marke, eintrag.modell].filter(Boolean).join(" ") || "—"}
/>
<Wertzeile label="DOT" wert={eintrag.dot || "—"} />
<Wertzeile
label="Luftdruck"
wert={`${eintrag.druck_vorne || "—"} / ${eintrag.druck_hinten || "—"} bar`}
zusatz="vorne / hinten"
/>
{Boolean(eintrag.kommentar) && <Wertzeile label="Notiz" wert={eintrag.kommentar!} />}
</>
}
/>
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => setzeBearbeiten(true)}>Bearbeiten</ActionButton>
</div>
</>
)}
</Accordion>
)
}
+4 -1
View File
@@ -6,6 +6,7 @@
import { useDaten } from "../daten/DatenKontext"
import type { Route, SeitenName } from "../navigation"
import { Batterie } from "./Batterie"
import { BatterieListe } from "./BatterieListe"
import { Einstellungen } from "./Einstellungen"
import { FahrtDetail } from "./FahrtDetail"
import { Fahrten } from "./Fahrten"
@@ -69,7 +70,9 @@ export function SeiteFuer(props: SeitenProps) {
case "ident":
return <Fahrzeugdaten />
case "battverlauf":
return <Batterie />
return <Batterie geheZu={geheZu} />
case "battliste":
return <BatterieListe />
case "service":
return <Service geheZu={geheZu} />
case "werkstatt":