Phase 7c: restliche Screens - alle 21 Seiten stehen

Fahrzeugdaten, Batterieverlauf, Service mit Werkstatt und Servicebuch,
Reifen, die ganze Versicherungs- und Steuergruppe, Einstellungen in voller
Tiefe und die Live-Ansicht.

Portiert dazu: die eigene Oelwechsel-Prognose (rechnet ab dem letzten
Wechsel im Servicebuch neu, weil der Langzeitschnitt dafuer zu traege ist),
Kalenderdateien fuer Termine und die CSV-Ausgabe im deutschen Format.

Die Live-Ansicht ist gebaut, aber ueber einen Schalter abgeschaltet: die
Werte dafuer liefert erst der FMM003. Ein Menuepunkt mit lauter Nullen waere
schlechter als keiner.

Zwei Stellen, an denen die App bewusst zurueckhaltend ist: ein unbekannter
Pruefpunkt erscheint als hohler Ring statt als geratenes Gruen, und der
Reifen-Kilometerstand wird nie zurueckgeschrieben, weil ihn der Server
fortschreibt.

49 Tests, darunter ein Rendertest ueber alle 21 Seiten in beiden Layouts
mit Beispieldaten in der Form, die das Backend wirklich liefert.
This commit is contained in:
Paul Nothaft
2026-08-11 11:17:47 +02:00
parent 8b64a808ed
commit 8e6e5bc2ee
16 changed files with 2258 additions and 54 deletions
+96
View File
@@ -0,0 +1,96 @@
/**
* Eigene Ölwechsel-Prognose. Portiert aus `oelwechselPrognose()` im alten
* Panel.
*
* 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.
*/
import type { Fahrzeug } from "./profilAdapter"
const TAG_MS = 86_400_000
export interface Oelprognose {
/** ISO-Tagesdatum. */
datum: string
restKm: number
kmProTag: number
/** Die Zeitgrenze hat gegriffen, nicht die Kilometergrenze. */
durchZeitlimit: boolean
}
interface Buchhaltung {
datum?: string
km?: number
art?: string
}
function monatePlus(isoDatum: string, monate: number): string {
const d = new Date(isoDatum)
if (Number.isNaN(d.getTime())) return isoDatum
d.setMonth(d.getMonth() + monate)
return d.toISOString().slice(0, 10)
}
/** Der jüngste Servicebucheintrag, dessen Art auf einen Ölwechsel hindeutet. */
export function letzterOelwechsel(buch: readonly Buchhaltung[]): Buchhaltung | null {
const treffer = buch
.filter((e) => typeof e.art === "string" && /öl|oel/i.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(
fahrzeug: Fahrzeug,
buch: readonly Buchhaltung[],
jetzt = new 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
const restKm = Math.max(0, zielKm - fahrzeug.odo)
const kappDatum = monatePlus(letzter.datum, Number.isFinite(intervallMonate) ? intervallMonate : 24)
if (restKm <= 0) {
return { datum: jetzt.toISOString().slice(0, 10), restKm: 0, kmProTag: 0, durchZeitlimit: false }
}
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
// seriöse Kilometerprognose — dann gilt das Zeitlimit.
if (tageSeit <= 0 || kmSeit <= 0) {
return { datum: kappDatum, restKm, kmProTag: 0, durchZeitlimit: true }
}
const kmProTag = kmSeit / tageSeit
const tageBisZiel = restKm / kmProTag
const ausKm = new Date(jetzt.getTime() + tageBisZiel * TAG_MS).toISOString().slice(0, 10)
const durchZeitlimit = kappDatum < ausKm
return {
datum: durchZeitlimit ? kappDatum : ausKm,
restKm,
kmProTag,
durchZeitlimit,
}
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Schalter für Funktionen, die gebaut, aber noch nicht nutzbar sind.
*
* Der Sinn: Die Oberfläche dafür existiert und ist geprüft, sie zeigt aber
* nichts an, solange keine echten Daten fließen. Ein sichtbarer Menüpunkt mit
* lauter Nullen wäre schlechter als gar keiner.
*/
/**
* Live-Ansicht der laufenden Fahrt (Position, Geschwindigkeit, Strecke).
*
* Braucht den FMM003 am CAN-Bus, der die Werte per MQTT nach Home Assistant
* schickt — siehe COMPANION_APP_ARCHITECTURE.md §2b und Phase 13 des
* Umsetzungsplans. **Erst umlegen, wenn die Zuordnungstabelle aus einer echten
* MQTT-Nachricht gebaut ist**, nicht vorher.
*/
export const LIVE_VERFUEGBAR = false
+177
View File
@@ -0,0 +1,177 @@
/**
* Batteriespannung im Verlauf. Vorlage: `vBatterieverlauf()`.
*
* Das Diagramm ist von Hand als SVG gezeichnet — wie im alten Panel und aus
* demselben Grund: eine Diagrammbibliothek wäre um ein Vielfaches größer als
* die ganze Seite.
*
* 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
* taugen nicht als Ruhewert.
*/
import { useEffect, useState } from "react"
import { Tile } from "@audi-dash/ui"
import { ENTITAETEN } from "../api"
import { useDaten } from "../daten/DatenKontext"
import { datum, de } from "../format"
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
interface Tageswert {
datum: string
min: number | null
max: number | null
min_ts?: string
max_ts?: string
}
/** Ab hier gilt eine Messung als nicht mehr in Ruhe aufgenommen. */
const AGM_RUHE_MAX_V = 13.2
/** Spannung → Ladezustand für eine AGM-Batterie in Ruhe. */
const LADEKURVE: [number, number][] = [
[12.8, 100],
[12.6, 90],
[12.4, 75],
[12.2, 50],
[12.0, 25],
[11.8, 10],
]
function ladezustand(spannung: number): number | null {
if (spannung > AGM_RUHE_MAX_V) return null
const ersterTreffer = LADEKURVE.find(([v]) => spannung >= v)
if (ersterTreffer) return ersterTreffer[1]
return 0
}
export function Batterie() {
const { api, fahrzeug } = useDaten()
const [verlauf, setzeVerlauf] = useState<Tageswert[] | null>(null)
const [fehler, setzeFehler] = useState(false)
useEffect(() => {
void api.rest
.datenLesen<Tageswert[]>(ENTITAETEN.batterieverlauf)
.then((daten) => setzeVerlauf(Array.isArray(daten) ? daten : []))
.catch(() => setzeFehler(true))
}, [api])
if (fehler) {
return (
<Leerzustand
titel="Verlauf nicht abrufbar"
text="Der Server hat den Batterieverlauf nicht geliefert. Beim nächsten Aufruf klappt es vielleicht wieder."
/>
)
}
if (!verlauf) return <div className="dm-laden">Verlauf wird geladen </div>
const messwerte = verlauf.filter((t) => t.min != null)
if (messwerte.length === 0) {
return (
<Leerzustand
titel="Keine Messwerte"
text="Für dieses Fahrzeug liefert die Anbindung derzeit keinen Spannungssensor. Sobald einer verfügbar ist, zeichnet die App den Verlauf hier auf."
/>
)
}
const letzter = messwerte[messwerte.length - 1]!
const soc = letzter.min != null ? ladezustand(letzter.min) : null
// Trend als Vergleich früher gegen später — keine echte Kapazitätsmessung.
const haelfte = Math.floor(messwerte.length / 2)
const mittel = (liste: Tageswert[]) =>
liste.length > 0 ? liste.reduce((s, t) => s + (t.min ?? 0), 0) / liste.length : null
const frueher = mittel(messwerte.slice(0, haelfte))
const spaeter = mittel(messwerte.slice(haelfte))
const trend = frueher != null && spaeter != null ? spaeter - frueher : null
return (
<>
<Tile>
<span className="ads-eyebrow">Ruhespannung</span>
<div className="dm-detail__kopf">
<div className="dm-detail__zahl">
{letzter.min != null ? de(letzter.min, 2) : "—"}
<span className="dm-detail__einheit">V</span>
</div>
</div>
<Verlaufsdiagramm werte={messwerte} />
<Werteliste
kinder={
<>
<Wertzeile label="Letzte Messung" wert={datum(letzter.datum)} />
<Wertzeile
label="Geschätzter Ladezustand"
wert={soc != null ? `${de(soc)} %` : "—"}
zusatz={soc == null ? "Messung nicht in Ruhe aufgenommen" : undefined}
/>
<Wertzeile
label="Trend über den Zeitraum"
wert={trend != null ? `${trend >= 0 ? "+" : ""}${de(trend, 2)} V` : "—"}
/>
<Wertzeile
label="Aktuell gemeldet"
wert={
fahrzeug?.batteriespannung != null
? `${de(fahrzeug.batteriespannung, 2)} V`
: "—"
}
/>
</>
}
/>
<p className="dm-fussnote">
Der Ladezustand wird aus dem Tagesminimum geschätzt das ist die Spannung kurz nach dem
Start, bevor die Lichtmaschine sie anhebt. Der Trend vergleicht die frühere mit der
späteren Hälfte des Zeitraums; er ist ein Hinweis, keine Kapazitätsmessung.
</p>
</Tile>
</>
)
}
/** Von Hand gezeichnete Verlaufskurve aus Tagesminimum und -maximum. */
function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
const breite = 320
const hoehe = 120
const rand = 4
const alleWerte = werte.flatMap((t) => [t.min, t.max].filter((v): v is number => v != null))
const kleinster = Math.min(...alleWerte) - 0.1
const groesster = Math.max(...alleWerte) + 0.1
const spanne = Math.max(0.2, groesster - kleinster)
const x = (i: number) =>
rand + (i / Math.max(1, werte.length - 1)) * (breite - 2 * rand)
const y = (v: number) => hoehe - rand - ((v - kleinster) / spanne) * (hoehe - 2 * rand)
const linie = (auswahl: (t: Tageswert) => number | null) =>
werte
.map((t, i) => {
const v = auswahl(t)
return v == null ? null : `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(v).toFixed(1)}`
})
.filter(Boolean)
.join(" ")
return (
<svg
className="dm-diagramm"
viewBox={`0 0 ${breite} ${hoehe}`}
preserveAspectRatio="none"
role="img"
aria-label={`Spannungsverlauf über ${werte.length} Tage`}
>
<path d={linie((t) => t.max)} fill="none" stroke="var(--fg3)" strokeWidth="1" />
<path d={linie((t) => t.min)} fill="none" stroke="var(--red)" strokeWidth="1.5" />
</svg>
)
}
+357
View File
@@ -0,0 +1,357 @@
/**
* Einstellungen. Vorlage: `vEinst()` im alten Panel — die umfangreichste
* Seite: Fahrzeugdaten, Darstellung, Fotos, SmartDeal, Pausenzeit, Sicherung,
* Datenausgabe und Zugang.
*/
import { useRef, useState } from "react"
import { ActionButton, Feld, Seg, Switch, Tile } from "@audi-dash/ui"
import { zugangVerwerfen } from "../api"
import { useDaten } from "../daten/DatenKontext"
import type { Einstellungen as EinstellungenWerte } from "../daten/profilAdapter"
import { datumZeit, de, isoTag } from "../format"
import { useTheme } from "../theme"
import { Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { Bild } from "./Bild"
import { BILDPLAETZE } from "./bilder"
import { csvHerunterladen } from "./csv"
import { zugangVergessen } from "./zugang"
export function Einstellungen({
tabBeschriftung,
setzeTabBeschriftung,
beiAbmeldung,
}: {
tabBeschriftung: boolean
setzeTabBeschriftung: (an: boolean) => void
beiAbmeldung: () => void
}) {
const { einstellungen, fahrzeug, fahrten, tankvorgaenge, rohprofil, api, profilSpeichern } =
useDaten()
const [theme, themeSetzen] = useTheme()
const [entwurf, setzeEntwurf] = useState<EinstellungenWerte | null>(null)
const [laeuft, setzeLaeuft] = useState(false)
const [gespeichert, setzeGespeichert] = useState(false)
const dateiwahl = useRef<HTMLInputElement | null>(null)
if (!einstellungen || !fahrzeug) return null
const werte = entwurf ?? einstellungen
const aendern = (teil: Partial<EinstellungenWerte>) =>
setzeEntwurf({ ...werte, ...teil })
const speichern = async () => {
if (!entwurf) return
setzeLaeuft(true)
setzeGespeichert(false)
try {
await profilSpeichern({ einstellungen: entwurf })
setzeEntwurf(null)
setzeGespeichert(true)
} finally {
setzeLaeuft(false)
}
}
const abmelden = async () => {
if (!bestaetigen("Zugang von diesem Gerät entfernen? Die Daten auf dem Server bleiben.")) return
await zugangVerwerfen()
zugangVergessen()
beiAbmeldung()
}
const profilExportieren = () => {
const inhalt = JSON.stringify(
{ profil: rohprofil, fahrten, tankvorgaenge, erzeugt: new Date().toISOString() },
null,
2,
)
const blob = new Blob([inhalt], { type: "application/json" })
const url = URL.createObjectURL(blob)
const verweis = document.createElement("a")
verweis.href = url
verweis.download = `datametric360-sicherung-${isoTag()}.json`
verweis.click()
URL.revokeObjectURL(url)
}
return (
<>
<Tile>
<span className="ads-eyebrow">Fahrzeug</span>
<Feld label="Modell">
<input
className="dm-eingabe"
value={werte.fahrzeugtitel}
onChange={(e) => aendern({ fahrzeugtitel: e.target.value })}
/>
</Feld>
<Feld label="Zusatz">
<input
className="dm-eingabe"
value={werte.fahrzeugzusatz}
onChange={(e) => aendern({ fahrzeugzusatz: e.target.value })}
/>
</Feld>
<Feld label="Kennzeichen">
<input
className="dm-eingabe"
value={werte.kennzeichen}
onChange={(e) => aendern({ kennzeichen: e.target.value })}
/>
</Feld>
<Feld label="Tankvolumen" unit="l">
<input
className="dm-eingabe"
inputMode="numeric"
value={werte.tankvolumen}
onChange={(e) => aendern({ tankvolumen: Number(e.target.value) || 0 })}
/>
</Feld>
<Feld label="WLAN des Fahrzeugs" last>
<input
className="dm-eingabe"
value={werte.wlanName}
onChange={(e) => aendern({ wlanName: e.target.value })}
/>
</Feld>
</Tile>
<Tile>
<span className="ads-eyebrow">Fahrten</span>
<Feld label="Pause zwischen zwei Fahrten" unit="Minuten">
<input
className="dm-eingabe"
inputMode="numeric"
value={werte.pauseMin}
onChange={(e) => aendern({ pauseMin: Number(e.target.value) || 0 })}
/>
</Feld>
<Feld label="Nacht beginnt">
<input
className="dm-eingabe"
type="time"
value={werte.nachtVon}
onChange={(e) => aendern({ nachtVon: e.target.value })}
/>
</Feld>
<Feld label="Nacht endet" last>
<input
className="dm-eingabe"
type="time"
value={werte.nachtBis}
onChange={(e) => aendern({ nachtBis: e.target.value })}
/>
</Feld>
<p className="dm-fussnote">
Setzt das Fahrzeug innerhalb der Pausenzeit die Fahrt fort, zählt das als eine Fahrt und
nicht als zwei etwa bei einem Tankstopp.
</p>
</Tile>
<Tile>
<span className="ads-eyebrow">Darstellung</span>
<Feld label="Erscheinungsbild">
<Seg
options={[
{ value: "nacht", label: "Nacht" },
{ value: "tag", label: "Tag" },
]}
value={theme}
onChange={(neu) => themeSetzen(neu as "nacht" | "tag")}
/>
</Feld>
<Feld label="Beschriftung der Tab-Leiste" last>
<Switch
checked={tabBeschriftung}
onChange={setzeTabBeschriftung}
aria-label="Beschriftung der Tab-Leiste"
/>
</Feld>
</Tile>
<Tile>
<span className="ads-eyebrow">Bild auf der Übersicht</span>
<Feld label="Verwendetes Bild" last>
<select
className="dm-auswahl"
value={werte.startbild}
onChange={(e) => aendern({ startbild: e.target.value })}
>
{BILDPLAETZE.map((platz) => (
<option key={platz.datei} value={platz.datei}>
{platz.name}
</option>
))}
<option value="seitenansicht-winter.webp">Seitenansicht Winter</option>
</select>
</Feld>
<div className="dm-galerie">
{BILDPLAETZE.map((platz) => (
<Bild
key={platz.datei}
datei={platz.datei}
beschreibung={platz.beschreibung}
name={platz.name}
groesse="mini"
/>
))}
</div>
<p className="dm-fussnote">
Fotos lädst du über die Verwaltung im Home Assistant hoch. Die Dateinamen sind fest
vergeben der Server nimmt nur diese an.
</p>
</Tile>
<Tile>
<span className="ads-eyebrow">Tankrabatt</span>
<Feld label={`${werte.markenname}-Rabatt aktiv`}>
<Switch
checked={fahrzeug.smartdeal.aktiv}
onChange={(an) =>
void profilSpeichern({
fahrzeug: {
...fahrzeug,
smartdeal: { ...fahrzeug.smartdeal, aktiv: an },
},
})
}
aria-label="Tankrabatt aktiv"
/>
</Feld>
<Feld label="Gültig bis" last>
<input
className="dm-eingabe"
type="date"
value={fahrzeug.smartdeal.laeuftAb ?? ""}
onChange={(e) =>
void profilSpeichern({
fahrzeug: {
...fahrzeug,
smartdeal: { ...fahrzeug.smartdeal, laeuftAb: e.target.value || null },
},
})
}
/>
</Feld>
<p className="dm-fussnote">
Das Datum gilt einschließlich am Stichtag selbst zählt der Rabatt noch, erst am Folgetag
schaltet er ab.
</p>
</Tile>
<Tile>
<span className="ads-eyebrow">Sicherung</span>
<Feld label="Automatische Sicherung">
<select
className="dm-auswahl"
value={werte.backupIntervall}
onChange={(e) => aendern({ backupIntervall: e.target.value })}
>
<option value="aus">aus</option>
<option value="taeglich">täglich</option>
<option value="woechentlich">wöchentlich</option>
<option value="monatlich">monatlich</option>
</select>
</Feld>
<Werteliste
kinder={
<Wertzeile
label="Zuletzt gesichert"
wert={werte.letztesBackup ? datumZeit(werte.letztesBackup) : "—"}
/>
}
/>
<div className="dm-knopfreihe">
<ActionButton
onClick={() =>
void api.rest.dienstAufrufen("pyscript", "audi_dashboard_backup_jetzt")
}
>
Jetzt sichern
</ActionButton>
<ActionButton onClick={profilExportieren}>Auf dieses Gerät laden</ActionButton>
</div>
</Tile>
<Tile>
<span className="ads-eyebrow">Daten ausgeben</span>
<p className="dm-fussnote">
Als CSV mit Semikolon und deutschem Zahlenformat so öffnet Excel die Datei ohne
Nachfragen.
</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>
</div>
</Tile>
<Tile>
<span className="ads-eyebrow">Zugang</span>
<Werteliste
kinder={
<>
<Wertzeile label="Fahrten gespeichert" wert={de(fahrten.length)} />
<Wertzeile label="Tankvorgänge gespeichert" wert={de(tankvorgaenge.length)} />
</>
}
/>
<div style={{ marginTop: 14 }}>
<ActionButton variant="destructive" onClick={() => void abmelden()}>
Verbindung trennen
</ActionButton>
</div>
</Tile>
<input ref={dateiwahl} type="file" accept="application/json" hidden />
{gespeichert && <p className="dm-erfolg">Gespeichert.</p>}
{entwurf && (
<div className="dm-speicherleiste">
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
{laeuft ? "Speichere …" : "Änderungen speichern"}
</ActionButton>
<button type="button" className="dm-textknopf" onClick={() => setzeEntwurf(null)}>
Verwerfen
</button>
</div>
)}
</>
)
}
+103
View File
@@ -0,0 +1,103 @@
/**
* Fahrzeugdaten: Identität, technisches Datenblatt, Ausstattung.
* Vorlage: `vIdent()` im alten Panel. Rein anzeigend — die Inhalte pflegt
* man im Profil, nicht hier.
*/
import { Accordion, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { datum, de } from "../format"
import { Wertzeile, Werteliste } from "./bausteine"
interface TechnikGruppe {
gruppe?: string
posten?: [string, string][]
}
interface AusstattungGruppe {
gruppe?: string
sonder?: string[]
serie?: string[]
}
export function Fahrzeugdaten() {
const { fahrzeug, einstellungen } = useDaten()
if (!fahrzeug || !einstellungen) return null
const technik = Array.isArray(fahrzeug.technik)
? (fahrzeug.technik as TechnikGruppe[])
: (Object.values(fahrzeug.technik) as TechnikGruppe[])
const ausstattung = Array.isArray(fahrzeug.ausstattung)
? (fahrzeug.ausstattung as AusstattungGruppe[])
: []
return (
<>
<Tile>
<span className="ads-eyebrow">Identität</span>
<Werteliste
kinder={
<>
<Wertzeile label="Modell" wert={einstellungen.fahrzeugtitel || "—"} />
<Wertzeile label="Ausführung" wert={einstellungen.ausfuehrung || fahrzeug.details || "—"} />
<Wertzeile label="Fahrgestellnummer" wert={fahrzeug.fin || "—"} />
<Wertzeile label="Kennzeichen" wert={einstellungen.kennzeichen || "—"} />
<Wertzeile
label="Erstzulassung"
wert={fahrzeug.erstzulassung ? datum(fahrzeug.erstzulassung) : "—"}
/>
<Wertzeile
label="Hauptuntersuchung fällig"
wert={fahrzeug.hu ? datum(fahrzeug.hu) : "—"}
/>
<Wertzeile
label="Kilometerstand"
wert={fahrzeug.odoBekannt ? `${de(fahrzeug.odo)} km` : "—"}
/>
</>
}
/>
</Tile>
{technik.length > 0 && (
<Tile>
<span className="ads-eyebrow">Technische Daten</span>
{technik.map((gruppe, i) => (
<Accordion key={gruppe.gruppe ?? i} title={gruppe.gruppe ?? "Weitere Angaben"}>
<Werteliste
kinder={(gruppe.posten ?? []).map(([bezeichnung, wert]) => (
<Wertzeile key={bezeichnung} label={bezeichnung} wert={wert} />
))}
/>
</Accordion>
))}
</Tile>
)}
{ausstattung.length > 0 && (
<Tile>
<span className="ads-eyebrow">Ausstattung</span>
{ausstattung.map((gruppe, i) => (
<Accordion
key={gruppe.gruppe ?? i}
title={gruppe.gruppe ?? "Weitere Ausstattung"}
summary={`${(gruppe.sonder?.length ?? 0) + (gruppe.serie?.length ?? 0)}`}
>
<ul className="dm-liste">
{(gruppe.sonder ?? []).map((posten) => (
<li key={posten}>{posten}</li>
))}
{(gruppe.serie ?? []).map((posten) => (
<li key={posten} className="dm-liste__serie">
{posten}
</li>
))}
</ul>
</Accordion>
))}
</Tile>
)}
</>
)
}
+87
View File
@@ -0,0 +1,87 @@
/**
* „Fahrt läuft gerade" — Live-Ansicht mit Karte, Geschwindigkeit, bisheriger
* Strecke und Dauer.
*
* Steht bereit, ist aber **abgeschaltet**: Die Daten dafür (Zündung, Position,
* Geschwindigkeit) liefert erst der FMM003, und der ist noch nicht verbaut
* (COMPANION_APP_ARCHITECTURE.md §2b, Phase 13 des Umsetzungsplans). Der
* Schalter liegt in `funktionen.ts` — umlegen, sobald echte Werte fließen,
* nicht vorher: bis dahin gäbe es nichts anzuzeigen außer Nullen.
*/
import { Fig, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { dauer, de, deOderStrich } from "../format"
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
import { Karte } from "./Karte"
export interface LiveZustand {
faehrt: boolean
geschwindigkeit?: number | null
position?: { lat: number; lon: number } | null
streckeKm?: number | null
dauerS?: number | null
}
/** Liest die Live-Werte aus dem Fahrzeugstatus. Solange der FMM003 fehlt,
liefert das Backend diese Felder nicht — dann ist alles null. */
export function liveZustandLesen(status: Record<string, unknown>): LiveZustand {
const lat = typeof status["lat"] === "number" ? status["lat"] : null
const lon = typeof status["lon"] === "number" ? status["lon"] : null
return {
faehrt: status["zuendung"] === true || status["zuendung"] === 1,
geschwindigkeit: typeof status["geschwindigkeit"] === "number" ? status["geschwindigkeit"] : null,
position: lat != null && lon != null ? { lat, lon } : null,
streckeKm: typeof status["fahrt_strecke_km"] === "number" ? status["fahrt_strecke_km"] : null,
dauerS: typeof status["fahrt_dauer_s"] === "number" ? status["fahrt_dauer_s"] : null,
}
}
export function LiveFahrt() {
const { fahrzeug } = useDaten()
if (!fahrzeug) return null
const live = liveZustandLesen(fahrzeug as unknown as Record<string, unknown>)
if (!live.faehrt) {
return (
<Leerzustand
titel="Das Fahrzeug steht"
text="Sobald die Zündung an ist, siehst du hier die laufende Fahrt mit Position, Geschwindigkeit, Strecke und Dauer."
/>
)
}
return (
<>
<Tile>
<span className="ads-eyebrow">Geschwindigkeit</span>
<Fig value={deOderStrich(live.geschwindigkeit)} unit="km/h" size={56} />
<Werteliste
kinder={
<>
<Wertzeile
label="Bisher gefahren"
wert={live.streckeKm != null ? `${de(live.streckeKm, 1)} km` : "—"}
/>
<Wertzeile label="Unterwegs seit" wert={dauer(live.dauerS)} />
</>
}
/>
</Tile>
<Tile>
<span className="ads-eyebrow">Position</span>
{live.position ? (
<Karte start={live.position} />
) : (
<Leerzustand
titel="Keine Position"
text="Das Fahrzeug meldet gerade keine Koordinaten."
/>
)}
</Tile>
</>
)
}
-50
View File
@@ -1,50 +0,0 @@
/**
* Vorläufiger Inhalt aller Seiten — wird in Phase 7 des Umsetzungsplans Seite
* für Seite durch die echten Ansichten ersetzt. Bis dahin belegt er die
* Navigation, damit sich der Rahmen bedienen lässt.
*/
import { Tile } from "@audi-dash/ui"
import type { Route, SeitenName } from "../navigation"
import { TITEL } from "../navigation"
const UNTERSEITEN: Partial<Record<SeitenName, SeitenName[]>> = {
home: ["sicherheit", "einst"],
audi: ["ident", "battverlauf", "service", "reifen", "vers"],
service: ["werkstatt", "sbuch"],
vers: ["beitrag", "vertragsdetails", "steuer", "notruf"],
vertragsdetails: ["schutz"],
trips: ["trip"],
fuel: ["fill"],
}
export function Platzhalter({
route,
geheZu,
}: {
route: Route
geheZu: (name: SeitenName, id?: string) => void
}) {
const weiter = UNTERSEITEN[route.name] ?? []
return (
<>
<Tile>
<div style={{ fontWeight: 300, fontSize: 18 }}>{TITEL[route.name]}</div>
<p style={{ color: "var(--fg2)", fontSize: 14, fontWeight: 300, margin: "8px 0 0" }}>
Diese Seite wird noch gebaut.
</p>
</Tile>
{weiter.length > 0 && (
<div className="dm-raster">
{weiter.map((ziel) => (
<Tile key={ziel} variant="button" chevron onClick={() => geheZu(ziel)}>
<span className="ads-eyebrow">weiter</span>
<div style={{ fontWeight: 300, fontSize: 16, marginTop: 4 }}>{TITEL[ziel]}</div>
</Tile>
))}
</div>
)}
</>
)
}
+157
View File
@@ -0,0 +1,157 @@
/**
* Reifensätze. Vorlage: `vReifen()` im alten Panel.
*
* Der Kilometerstand je Satz kommt vom Backend (`reifenzaehler.py`) und wird
* hier nie zurückgeschrieben — sonst überschriebe ein im Browser gehaltener
* älterer Stand eine zwischenzeitliche Fortschreibung. Der Wechsel läuft
* deshalb auch über einen eigenen Dienst statt über das Profil.
*/
import { useState } from "react"
import { ActionButton, Feld, Seg, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import type { Reifensatz } from "../daten/profilAdapter"
import { datum, de, deOderStrich } from "../format"
import { Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { Bild } from "./Bild"
import { kalenderDateiLaden } from "./kalender"
export function Reifen() {
const { fahrzeug, api, profilSpeichern } = useDaten()
const [laeuft, setzeLaeuft] = useState(false)
const [gezeigt, setzeGezeigt] = useState<"Sommer" | "Winter" | null>(null)
if (!fahrzeug) return null
const aktiv = fahrzeug.reifen.aktiv
const anzeige = gezeigt ?? aktiv
const satz: Reifensatz = anzeige === "Winter" ? fahrzeug.reifen.winter : fahrzeug.reifen.sommer
const wechsel = fahrzeug.reifen.wechsel as { datum?: string; vereinbart?: boolean }
const wechseln = async () => {
const ziel = aktiv === "Sommer" ? "winter" : "sommer"
if (!bestaetigen(`Auf ${ziel === "winter" ? "Winter" : "Sommer"}reifen umstellen?`)) return
setzeLaeuft(true)
try {
// Eigener Dienst: das Backend rechnet zuerst den alten Satz ab und
// schaltet erst dann um, damit keine Kilometer auf den neuen Satz
// rutschen, die noch auf dem alten gefahren wurden.
await api.rest.dienstAufrufen("pyscript", "audi_dashboard_reifen_wechseln", { satz: ziel })
} finally {
setzeLaeuft(false)
}
}
const wechseldatumSetzen = async (neuesDatum: string) => {
await profilSpeichern({
fahrzeug: {
...fahrzeug,
reifen: { ...fahrzeug.reifen, wechsel: { ...wechsel, datum: neuesDatum } },
},
})
}
return (
<>
<Tile>
<span className="ads-eyebrow">Montiert</span>
<div className="dm-detail__kopf">
<div className="dm-detail__zahl">{aktiv}</div>
<ActionButton onClick={() => void wechseln()} disabled={laeuft}>
{laeuft ? "Stelle um …" : `Auf ${aktiv === "Sommer" ? "Winter" : "Sommer"} wechseln`}
</ActionButton>
</div>
<Werteliste
kinder={
<>
<Wertzeile
label="Anzugsmoment der Radschrauben"
wert={
fahrzeug.reifen.anzugsmoment != null
? `${de(fahrzeug.reifen.anzugsmoment)} Nm`
: "—"
}
/>
<Wertzeile
label="Nächster Wechseltermin"
wert={wechsel.datum ? datum(wechsel.datum) : "—"}
/>
</>
}
/>
<Feld label="Wechseltermin" last>
<input
className="dm-eingabe"
type="date"
value={wechsel.datum ?? ""}
onChange={(e) => void wechseldatumSetzen(e.target.value)}
/>
</Feld>
{wechsel.datum && (
<div style={{ marginTop: 14 }}>
<ActionButton
onClick={() =>
kalenderDateiLaden(
"Reifenwechsel",
wechsel.datum!,
`Wechsel auf ${aktiv === "Sommer" ? "Winter" : "Sommer"}reifen`,
)
}
>
In den Kalender eintragen
</ActionButton>
</div>
)}
</Tile>
<Seg
options={[
{ value: "Sommer", label: "Sommer" },
{ value: "Winter", label: "Winter" },
]}
value={anzeige}
onChange={(wert) => setzeGezeigt(wert as "Sommer" | "Winter")}
/>
<Tile>
<span className="ads-eyebrow">
{anzeige}reifen {anzeige === aktiv ? "· montiert" : "· eingelagert"}
</span>
<Bild
datei={anzeige === "Winter" ? "rad-winter.webp" : "rad-sommer.webp"}
beschreibung={`${anzeige}rad`}
name={`${anzeige}rad`}
/>
<Werteliste
kinder={
<>
<Wertzeile label="Gefahrene Strecke" wert={`${deOderStrich(satz["km"] as number)} km`} />
<Wertzeile label="Größe" wert={(satz["mass"] as string) || "—"} />
<Wertzeile
label="Marke und Modell"
wert={
[satz["marke"], satz["modell"]].filter(Boolean).join(" ") || "—"
}
/>
<Wertzeile label="DOT" wert={(satz["dot"] as string) || "—"} />
<Wertzeile
label="Luftdruck"
wert={`${(satz["druck_vorne"] as string) || "—"} / ${(satz["druck_hinten"] as string) || "—"} bar`}
zusatz="vorne / hinten"
/>
{Boolean(satz["kommentar"]) && (
<Wertzeile label="Notiz" wert={satz["kommentar"] as string} />
)}
</>
}
/>
<p className="dm-fussnote">
Die gefahrene Strecke schreibt der Server bei jeder Änderung des Kilometerstands fort und
rechnet sie immer dem gerade montierten Satz zu.
</p>
</Tile>
</>
)
}
+311
View File
@@ -0,0 +1,311 @@
/**
* Service, Werkstatt und Servicebuch. Vorlagen: `vService()`, `vWerkstatt()`,
* `vSbuch()`.
*
* Die vom Fahrzeug gemeldete Fälligkeit und die aus dem Servicebuch
* gerechnete Prognose stehen bewusst nebeneinander statt sich zu ersetzen:
* Die Logik im Werkscomputer ist nicht bekannt und kann von den selbst
* eingestellten Intervallen abweichen.
*/
import { useState } from "react"
import { ActionButton, Feld, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { oelwechselPrognose } from "../daten/service"
import { datum, de, isoTag } from "../format"
import type { SeitenName } from "../navigation"
import { Leerzustand, NaviKachel, Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { kalenderDateiLaden } from "./kalender"
export interface ServicebuchEintrag {
datum?: string
km?: number
art?: string
betrieb?: string
notiz?: string
[weitere: string]: unknown
}
interface Autohaus {
name?: string
adresse?: string
tel?: string
mail?: string
}
export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
const { fahrzeug } = useDaten()
if (!fahrzeug) return null
const buch = (fahrzeug.service["buch"] ?? []) as ServicebuchEintrag[]
const autohaus = (fahrzeug.service["autohaus"] ?? {}) as Autohaus
const prognose = oelwechselPrognose(fahrzeug, buch)
return (
<>
<Tile>
<span className="ads-eyebrow">Nächster Ölwechsel</span>
<Werteliste
kinder={
<>
<Wertzeile
label="Vom Fahrzeug gemeldet"
wert={fahrzeug.oelwechselFaelligTs ? datum(fahrzeug.oelwechselFaelligTs) : "—"}
zusatz={
fahrzeug.oelwechselFaelligKm != null
? `in ${de(Math.abs(fahrzeug.oelwechselFaelligKm))} km`
: undefined
}
/>
<Wertzeile
label="Eigene Prognose"
wert={prognose ? datum(prognose.datum) : "—"}
zusatz={
prognose
? `${de(prognose.restKm)} km offen · ${de(prognose.kmProTag, 1)} km/Tag`
: "Noch kein Ölwechsel im Servicebuch"
}
/>
<Wertzeile
label="Nächste Inspektion"
wert={fahrzeug.inspektionFaelligTs ? datum(fahrzeug.inspektionFaelligTs) : "—"}
zusatz={
fahrzeug.inspektionFaelligKm != null
? `in ${de(Math.abs(fahrzeug.inspektionFaelligKm))} km`
: undefined
}
/>
<Wertzeile
label="Hauptuntersuchung"
wert={fahrzeug.hu ? datum(fahrzeug.hu) : "—"}
/>
</>
}
/>
{prognose && (
<div style={{ marginTop: 14 }}>
<ActionButton
onClick={() =>
kalenderDateiLaden("Ölwechsel", prognose.datum, "Ölwechsel laut eigener Prognose")
}
>
In den Kalender eintragen
</ActionButton>
</div>
)}
<p className="dm-fussnote">
Die eigene Prognose rechnet ab dem letzten Ölwechsel im Servicebuch neu und nutzt die
seither tatsächlich gefahrene Strecke. Sie ersetzt die Meldung des Fahrzeugs nicht,
sondern ergänzt sie.
</p>
</Tile>
<div className="dm-raster">
<NaviKachel
titel="Werkstatt"
wert={autohaus.name || "Noch nicht hinterlegt"}
beiKlick={() => geheZu("werkstatt")}
/>
<NaviKachel
titel="Servicebuch"
wert={`${de(buch.length)} Eintrag${buch.length === 1 ? "" : "e"}`}
beiKlick={() => geheZu("sbuch")}
/>
</div>
</>
)
}
export function Werkstatt() {
const { fahrzeug, profilSpeichern } = useDaten()
const autohausRoh = (fahrzeug?.service["autohaus"] ?? {}) as Autohaus
const [entwurf, setzeEntwurf] = useState<Autohaus>({ ...autohausRoh })
const [laeuft, setzeLaeuft] = useState(false)
const [gespeichert, setzeGespeichert] = useState(false)
if (!fahrzeug) return null
const speichern = async () => {
setzeLaeuft(true)
setzeGespeichert(false)
try {
const neu = { ...fahrzeug, service: { ...fahrzeug.service, autohaus: entwurf } }
await profilSpeichern({ fahrzeug: neu })
setzeGespeichert(true)
} finally {
setzeLaeuft(false)
}
}
const feld = (schluessel: keyof Autohaus, beschriftung: string, letztes = false) => (
<Feld label={beschriftung} last={letztes}>
<input
className="dm-eingabe"
value={entwurf[schluessel] ?? ""}
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, [schluessel]: e.target.value }))}
/>
</Feld>
)
return (
<Tile>
<span className="ads-eyebrow">Werkstatt</span>
{feld("name", "Name")}
{feld("adresse", "Adresse")}
{feld("tel", "Telefon")}
{feld("mail", "E-Mail", true)}
{gespeichert && <p className="dm-erfolg">Gespeichert.</p>}
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
{laeuft ? "Speichere …" : "Speichern"}
</ActionButton>
</div>
</Tile>
)
}
export function Servicebuch() {
const { fahrzeug, profilSpeichern } = useDaten()
const [neuOffen, setzeNeuOffen] = useState(false)
const [entwurf, setzeEntwurf] = useState<ServicebuchEintrag>({ datum: isoTag(), art: "Ölwechsel" })
const [laeuft, setzeLaeuft] = useState(false)
if (!fahrzeug) return null
const buch = (fahrzeug.service["buch"] ?? []) as ServicebuchEintrag[]
const schreiben = async (neuesBuch: ServicebuchEintrag[]) => {
setzeLaeuft(true)
try {
await profilSpeichern({
fahrzeug: { ...fahrzeug, service: { ...fahrzeug.service, buch: neuesBuch } },
})
} finally {
setzeLaeuft(false)
}
}
const hinzufuegen = async () => {
await schreiben(
[...buch, entwurf].sort((a, b) => String(b.datum ?? "").localeCompare(String(a.datum ?? ""))),
)
setzeNeuOffen(false)
setzeEntwurf({ datum: isoTag(), art: "Ölwechsel" })
}
const entfernen = async (index: number) => {
if (!bestaetigen("Diesen Servicebuch-Eintrag löschen?")) return
await schreiben(buch.filter((_, i) => i !== index))
}
return (
<>
<div className="dm-abschnitt">
<span className="ads-eyebrow">{de(buch.length)} Einträge</span>
<button
type="button"
className="dm-textknopf"
onClick={() => setzeNeuOffen((offen) => !offen)}
>
{neuOffen ? "Abbrechen" : "Eintrag hinzufügen"}
</button>
</div>
{neuOffen && (
<Tile>
<span className="ads-eyebrow">Neuer Eintrag</span>
<Feld label="Datum">
<input
className="dm-eingabe"
type="date"
value={entwurf.datum ?? ""}
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, datum: e.target.value }))}
/>
</Feld>
<Feld label="Art">
<select
className="dm-auswahl"
value={entwurf.art ?? "Ölwechsel"}
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, art: e.target.value }))}
>
<option>Ölwechsel</option>
<option>Inspektion</option>
<option>Hauptuntersuchung</option>
<option>Reparatur</option>
<option>Sonstiges</option>
</select>
</Feld>
<Feld label="Kilometerstand" unit="km">
<input
className="dm-eingabe"
inputMode="numeric"
value={entwurf.km ?? ""}
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, km: Number(e.target.value) }))}
/>
</Feld>
<Feld label="Betrieb">
<input
className="dm-eingabe"
value={entwurf.betrieb ?? ""}
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, betrieb: e.target.value }))}
/>
</Feld>
<Feld label="Notiz" last>
<input
className="dm-eingabe"
value={entwurf.notiz ?? ""}
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, notiz: e.target.value }))}
/>
</Feld>
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => void hinzufuegen()} disabled={laeuft}>
Speichern
</ActionButton>
</div>
</Tile>
)}
{buch.length === 0 && !neuOffen && (
<Leerzustand
titel="Servicebuch ist leer"
text="Trag hier ein, was am Fahrzeug gemacht wurde. Aus den Ölwechseln entsteht die eigene Wartungsprognose."
aktion={{ beschriftung: "Eintrag hinzufügen", beiKlick: () => setzeNeuOffen(true) }}
/>
)}
{buch.map((eintrag, index) => (
<Tile key={`${eintrag.datum}-${index}`}>
<div className="dm-detail__kopf">
<div>
<span className="ads-eyebrow">{eintrag.datum ? datum(eintrag.datum) : "ohne Datum"}</span>
<div style={{ fontSize: 17, fontWeight: 300, marginTop: 4 }}>
{eintrag.art ?? "Eintrag"}
</div>
</div>
<button
type="button"
className="dm-textknopf"
onClick={() => void entfernen(index)}
disabled={laeuft}
>
Löschen
</button>
</div>
<Werteliste
kinder={
<>
<Wertzeile
label="Kilometerstand"
wert={eintrag.km != null ? `${de(eintrag.km)} km` : "—"}
/>
<Wertzeile label="Betrieb" wert={eintrag.betrieb || "—"} />
{eintrag.notiz && <Wertzeile label="Notiz" wert={eintrag.notiz} />}
</>
}
/>
</Tile>
))}
</>
)
}
+404
View File
@@ -0,0 +1,404 @@
/**
* Versicherung, Vertragsdetails, Schutzbrief, Notruf und Kfz-Steuer.
* Vorlagen: `vVers()`, `vBeitrag()`, `vVertragsdetails()`, `vSchutz()`,
* `vNotrufBearbeiten()`, `vSteuer()`.
*
* Alle Formulare hier schreiben über `profilSpeichern()` das **ganze** Profil
* zurück — das Backend kennt keinen Dienst für einzelne Felder.
*/
import { useState } from "react"
import { ActionButton, Feld, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { datum, de, eur } from "../format"
import type { SeitenName } from "../navigation"
import { Leerzustand, NaviKachel, Wertzeile, Werteliste } from "./bausteine"
interface Beitragsposten {
bez?: string
betrag?: number
sf?: string
satz?: string
}
export function Versicherung({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
const { fahrzeug } = useDaten()
if (!fahrzeug) return null
const v = fahrzeug.versicherung
const steuer = fahrzeug.steuer
return (
<>
<Tile>
<span className="ads-eyebrow">Versicherung</span>
<Werteliste
kinder={
<>
<Wertzeile label="Gesellschaft" wert={(v["gesellschaft"] as string) || "—"} />
<Wertzeile label="Vertrag" wert={(v["vertrag"] as string) || "—"} />
<Wertzeile label="Vertragsnummer" wert={(v["nummer"] as string) || "—"} />
<Wertzeile
label="Beitrag"
wert={v["beitrag"] ? `${eur(v["beitrag"] as number)}` : "—"}
zusatz={(v["zahlweise"] as string) || undefined}
/>
<Wertzeile
label="Gültig ab"
wert={v["gueltigAb"] ? datum(v["gueltigAb"] as string) : "—"}
/>
</>
}
/>
</Tile>
<div className="dm-raster">
<NaviKachel titel="Beitrag" wert="Einzelposten bearbeiten" beiKlick={() => geheZu("beitrag")} />
<NaviKachel
titel="Vertragsdetails"
wert="Leistungen und Schutzbrief"
beiKlick={() => geheZu("vertragsdetails")}
/>
<NaviKachel
titel="Notruf"
wert={(v["notfall"] as string) || "Nummern hinterlegen"}
beiKlick={() => geheZu("notruf")}
/>
<NaviKachel
titel="Kfz-Steuer"
wert={steuer["betrag"] ? `${eur(steuer["betrag"] as number)}` : "Noch nicht hinterlegt"}
beiKlick={() => geheZu("steuer")}
/>
</div>
</>
)
}
export function Beitrag() {
const { fahrzeug, profilSpeichern } = useDaten()
const [laeuft, setzeLaeuft] = useState(false)
const [gespeichert, setzeGespeichert] = useState(false)
if (!fahrzeug) return null
const v = fahrzeug.versicherung
const teile = (v["teile"] ?? []) as Beitragsposten[]
const [entwurf, setzeEntwurf] = useState<Beitragsposten[]>(() => teile.map((t) => ({ ...t })))
const speichern = async () => {
setzeLaeuft(true)
setzeGespeichert(false)
try {
const summeBetrag = entwurf.reduce((s, t) => s + (Number(t.betrag) || 0), 0)
await profilSpeichern({
fahrzeug: {
...fahrzeug,
versicherung: { ...v, teile: entwurf, beitrag: summeBetrag },
},
})
setzeGespeichert(true)
} finally {
setzeLaeuft(false)
}
}
if (entwurf.length === 0) {
return (
<Leerzustand
titel="Keine Beitragsposten"
text="Sobald im Profil Einzelposten hinterlegt sind, kannst du sie hier bearbeiten."
/>
)
}
return (
<Tile>
<span className="ads-eyebrow">Beitragsposten</span>
{entwurf.map((posten, index) => (
<Feld
key={`${posten.bez}-${index}`}
label={posten.bez ?? `Posten ${index + 1}`}
unit="€"
last={index === entwurf.length - 1}
>
<input
className="dm-eingabe"
inputMode="decimal"
value={posten.betrag ?? ""}
onChange={(e) =>
setzeEntwurf((alt) =>
alt.map((p, i) =>
i === index ? { ...p, betrag: Number(e.target.value.replace(",", ".")) } : p,
),
)
}
/>
</Feld>
))}
<Werteliste
kinder={
<Wertzeile
label="Summe"
wert={`${eur(entwurf.reduce((s, t) => s + (Number(t.betrag) || 0), 0))}`}
/>
}
/>
{gespeichert && <p className="dm-erfolg">Gespeichert.</p>}
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
{laeuft ? "Speichere …" : "Speichern"}
</ActionButton>
</div>
</Tile>
)
}
export function Vertragsdetails({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
const { fahrzeug } = useDaten()
if (!fahrzeug) return null
const v = fahrzeug.versicherung
const leistungen = (v["leistungen"] ?? []) as { bez?: string; wert?: string }[]
const selbst = (v["selbst"] ?? []) as (string | number)[][]
return (
<>
<Tile>
<span className="ads-eyebrow">Leistungen</span>
{leistungen.length === 0 ? (
<p className="dm-fussnote">Im Profil sind noch keine Leistungen hinterlegt.</p>
) : (
<Werteliste
kinder={leistungen.map((l, i) => (
<Wertzeile key={`${l.bez}-${i}`} label={l.bez ?? "—"} wert={l.wert ?? "—"} />
))}
/>
)}
</Tile>
{selbst.length > 0 && (
<Tile>
<span className="ads-eyebrow">Selbstbeteiligung</span>
<Werteliste
kinder={selbst.map((zeile, i) => (
<Wertzeile key={i} label={String(zeile[0] ?? "")} wert={String(zeile[1] ?? "")} />
))}
/>
</Tile>
)}
<NaviKachel
titel="Schutzbrief"
wert="Bedingungen im Detail"
beiKlick={() => geheZu("schutz")}
/>
</>
)
}
export function Schutzbrief() {
const { fahrzeug } = useDaten()
if (!fahrzeug) return null
const s = (fahrzeug.versicherung["schutzbrief"] ?? {}) as Record<string, unknown>
const listen: [string, string[]][] = [
["Auslöser", (s["ausloeser"] ?? []) as string[]],
["Entweder", (s["entweder"] ?? []) as string[]],
["Leistungsgruppen", (s["gruppen"] ?? []) as string[]],
["Nicht versichert", (s["nicht"] ?? []) as string[]],
["Pflichten", (s["pflichten"] ?? []) as string[]],
]
const hatInhalt = listen.some(([, eintraege]) => eintraege.length > 0)
if (!hatInhalt) {
return (
<Leerzustand
titel="Keine Schutzbrief-Angaben"
text="Im Profil sind zum Schutzbrief noch keine Bedingungen hinterlegt."
/>
)
}
return (
<>
<Tile>
<span className="ads-eyebrow">Schutzbrief</span>
<Werteliste
kinder={
<>
<Wertzeile label="Stand" wert={(s["stand"] as string) || "—"} />
<Wertzeile label="Gilt" wert={(s["gilt"] as string) || "—"} />
<Wertzeile label="Versichert" wert={(s["versichert"] as string) || "—"} />
</>
}
/>
</Tile>
{listen
.filter(([, eintraege]) => eintraege.length > 0)
.map(([titel, eintraege]) => (
<Tile key={titel}>
<span className="ads-eyebrow">{titel}</span>
<ul className="dm-liste">
{eintraege.map((eintrag) => (
<li key={eintrag}>{eintrag}</li>
))}
</ul>
</Tile>
))}
</>
)
}
export function Notruf() {
const { fahrzeug, profilSpeichern } = useDaten()
const v = fahrzeug?.versicherung ?? {}
const [service, setzeService] = useState((v["service"] as string) ?? "")
const [notfall, setzeNotfall] = useState((v["notfall"] as string) ?? "")
const [laeuft, setzeLaeuft] = useState(false)
const [gespeichert, setzeGespeichert] = useState(false)
if (!fahrzeug) return null
const speichern = async () => {
setzeLaeuft(true)
setzeGespeichert(false)
try {
await profilSpeichern({
fahrzeug: { ...fahrzeug, versicherung: { ...v, service, notfall } },
})
setzeGespeichert(true)
} finally {
setzeLaeuft(false)
}
}
return (
<Tile>
<span className="ads-eyebrow">Nummern im Notfall</span>
<Feld label="Service">
<input
className="dm-eingabe"
type="tel"
value={service}
onChange={(e) => setzeService(e.target.value)}
/>
</Feld>
<Feld label="Notfall" last>
<input
className="dm-eingabe"
type="tel"
value={notfall}
onChange={(e) => setzeNotfall(e.target.value)}
/>
</Feld>
{(service || notfall) && (
<div className="dm-waehlen">
{service && (
<a className="dm-waehlen__knopf" href={`tel:${service.replace(/\s/g, "")}`}>
Service anrufen
</a>
)}
{notfall && (
<a className="dm-waehlen__knopf" href={`tel:${notfall.replace(/\s/g, "")}`}>
Notfall anrufen
</a>
)}
</div>
)}
{gespeichert && <p className="dm-erfolg">Gespeichert.</p>}
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
{laeuft ? "Speichere …" : "Speichern"}
</ActionButton>
</div>
</Tile>
)
}
export function Steuer() {
const { fahrzeug, profilSpeichern } = useDaten()
const [entwurf, setzeEntwurf] = useState(() => ({ ...(fahrzeug?.steuer ?? {}) }))
const [laeuft, setzeLaeuft] = useState(false)
const [gespeichert, setzeGespeichert] = useState(false)
if (!fahrzeug) return null
const speichern = async () => {
setzeLaeuft(true)
setzeGespeichert(false)
try {
await profilSpeichern({
fahrzeug: { ...fahrzeug, steuer: entwurf as typeof fahrzeug.steuer },
})
setzeGespeichert(true)
} finally {
setzeLaeuft(false)
}
}
return (
<Tile>
<span className="ads-eyebrow">Kfz-Steuer</span>
<Feld label="Betrag" unit="€">
<input
className="dm-eingabe"
inputMode="decimal"
value={(entwurf["betrag"] as number) ?? ""}
onChange={(e) =>
setzeEntwurf((alt) => ({ ...alt, betrag: Number(e.target.value.replace(",", ".")) }))
}
/>
</Feld>
<Feld label="Zeitraum">
<input
className="dm-eingabe"
value={(entwurf["zeitraum"] as string) ?? ""}
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, zeitraum: e.target.value }))}
/>
</Feld>
<Feld label="Fällig am">
<input
className="dm-eingabe"
type="date"
value={(entwurf["faellig"] as string) ?? ""}
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, faellig: e.target.value }))}
/>
</Feld>
<Feld label="Finanzamt" last>
<input
className="dm-eingabe"
value={(entwurf["finanzamt"] as string) ?? ""}
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, finanzamt: e.target.value }))}
/>
</Feld>
<Werteliste
kinder={
<>
<Wertzeile label="Grundlage" wert={(entwurf["grundlage"] as string) || "—"} />
<Wertzeile
label="Lastschrift"
wert={entwurf["lastschrift"] ? "eingerichtet" : "nicht eingerichtet"}
/>
<Wertzeile
label="Monatlich gerechnet"
wert={
entwurf["betrag"] ? `${eur((entwurf["betrag"] as number) / 12)}` : "—"
}
/>
</>
}
/>
{gespeichert && <p className="dm-erfolg">Gespeichert.</p>}
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
{laeuft ? "Speichere …" : "Speichern"}
</ActionButton>
</div>
<p className="dm-fussnote">
{de(1)} Fahrzeug, {(entwurf["zeitraum"] as string) || "jährlich"} zu zahlen.
</p>
</Tile>
)
}
+33
View File
@@ -0,0 +1,33 @@
/**
* CSV-Ausgabe. Portiert aus dem alten Panel: Semikolon als Trennzeichen,
* deutsches Zahlenformat und ein BOM am Anfang — nur damit öffnet Excel die
* Datei ohne Rückfragen richtig.
*/
function feld(wert: unknown): string {
if (wert === null || wert === undefined) return ""
if (typeof wert === "number") return String(wert).replace(".", ",")
const text = String(wert)
return /[";\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
}
export function alsCsv(kopf: readonly string[], zeilen: readonly unknown[][]): string {
const inhalt = [kopf.join(";"), ...zeilen.map((z) => z.map(feld).join(";"))].join("\r\n")
return `${inhalt}`
}
export function csvHerunterladen(
name: string,
kopf: readonly string[],
zeilen: readonly unknown[][],
): void {
const blob = new Blob([alsCsv(kopf, zeilen)], { type: "text/csv;charset=utf-8" })
const url = URL.createObjectURL(blob)
const verweis = document.createElement("a")
verweis.href = url
verweis.download = `${name}.csv`
document.body.appendChild(verweis)
verweis.click()
verweis.remove()
URL.revokeObjectURL(url)
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Termine als Kalenderdatei. Portiert aus `ics()` im alten Panel.
*
* Erzeugt eine .ics-Datei im Browser, ohne Server — sie landet als Download
* bzw. auf dem iPhone direkt im Kalender. Mit Erinnerung zwei Tage vorher.
*/
function zeilenUmbruch(text: string): string {
return text.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n")
}
function alsIcsDatum(isoTag: string): string {
return isoTag.replace(/-/g, "")
}
export function kalenderDatei(titel: string, isoTag: string, beschreibung = ""): string {
const stempel = new Date().toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"
const start = alsIcsDatum(isoTag)
const ende = alsIcsDatum(
new Date(new Date(isoTag).getTime() + 86_400_000).toISOString().slice(0, 10),
)
return [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//DataMetric360//DE",
"CALSCALE:GREGORIAN",
"BEGIN:VEVENT",
`UID:dm360-${start}-${Math.abs(hash(titel))}@datametric360`,
`DTSTAMP:${stempel}`,
`DTSTART;VALUE=DATE:${start}`,
`DTEND;VALUE=DATE:${ende}`,
`SUMMARY:${zeilenUmbruch(titel)}`,
beschreibung ? `DESCRIPTION:${zeilenUmbruch(beschreibung)}` : "",
"BEGIN:VALARM",
"TRIGGER:-P2D",
"ACTION:DISPLAY",
`DESCRIPTION:${zeilenUmbruch(titel)}`,
"END:VALARM",
"END:VEVENT",
"END:VCALENDAR",
]
.filter(Boolean)
.join("\r\n")
}
function hash(text: string): number {
let wert = 0
for (let i = 0; i < text.length; i++) wert = (wert * 31 + text.charCodeAt(i)) | 0
return wert
}
export function kalenderDateiLaden(titel: string, isoTag: string, beschreibung = ""): void {
const inhalt = kalenderDatei(titel, isoTag, beschreibung)
const blob = new Blob([inhalt], { type: "text/calendar;charset=utf-8" })
const url = URL.createObjectURL(blob)
const verweis = document.createElement("a")
verweis.href = url
verweis.download = `${titel.replace(/[^\wäöüÄÖÜß-]+/g, "-").toLowerCase()}.ics`
document.body.appendChild(verweis)
verweis.click()
verweis.remove()
URL.revokeObjectURL(url)
}
+49 -4
View File
@@ -5,15 +5,28 @@
import { useDaten } from "../daten/DatenKontext"
import type { Route, SeitenName } from "../navigation"
import { Batterie } from "./Batterie"
import { Einstellungen } from "./Einstellungen"
import { FahrtDetail } from "./FahrtDetail"
import { Fahrten } from "./Fahrten"
import { Fahrzeugdaten } from "./Fahrzeugdaten"
import { Fahrzeugstatus } from "./Fahrzeugstatus"
import { LiveFahrt } from "./LiveFahrt"
import { MeinAudi } from "./MeinAudi"
import { Platzhalter } from "./Platzhalter"
import { Reifen } from "./Reifen"
import { Service, Servicebuch, Werkstatt } from "./Service"
import { Statistik } from "./Statistik"
import { TankDetail } from "./TankDetail"
import { Tanken } from "./Tanken"
import { Uebersicht } from "./Uebersicht"
import {
Beitrag,
Notruf,
Schutzbrief,
Steuer,
Versicherung,
Vertragsdetails,
} from "./Versicherung"
export interface SeitenProps {
route: Route
@@ -40,7 +53,7 @@ export function SeiteFuer(props: SeitenProps) {
return <div className="dm-laden">Daten werden geladen </div>
}
const { route, geheZu } = props
const { route, geheZu, tabBeschriftung, setzeTabBeschriftung, beiAbmeldung } = props
switch (route.name) {
case "home":
@@ -49,6 +62,30 @@ export function SeiteFuer(props: SeitenProps) {
return <Fahrzeugstatus />
case "audi":
return <MeinAudi geheZu={geheZu} />
case "ident":
return <Fahrzeugdaten />
case "battverlauf":
return <Batterie />
case "service":
return <Service geheZu={geheZu} />
case "werkstatt":
return <Werkstatt />
case "sbuch":
return <Servicebuch />
case "reifen":
return <Reifen />
case "vers":
return <Versicherung geheZu={geheZu} />
case "beitrag":
return <Beitrag />
case "vertragsdetails":
return <Vertragsdetails geheZu={geheZu} />
case "schutz":
return <Schutzbrief />
case "notruf":
return <Notruf />
case "steuer":
return <Steuer />
case "trips":
return <Fahrten geheZu={geheZu} />
case "trip":
@@ -59,7 +96,15 @@ export function SeiteFuer(props: SeitenProps) {
return <TankDetail id={route.id} />
case "stat":
return <Statistik />
default:
return <Platzhalter route={route} geheZu={geheZu} />
case "live":
return <LiveFahrt />
case "einst":
return (
<Einstellungen
tabBeschriftung={tabBeschriftung}
setzeTabBeschriftung={setzeTabBeschriftung}
beiAbmeldung={beiAbmeldung}
/>
)
}
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Abnahme für Phase 7 des Umsetzungsplans: Jede Seite muss mit echten
* Beispieldaten rendern — in beiden Layouts — und die Leerzustände müssen
* greifen, statt dass eine leere Liste die Seite zerlegt.
*/
import { render, screen, waitFor } from "@testing-library/react"
import { beforeEach, describe, expect, it } from "vitest"
import { DatenAnbieter } from "../daten/DatenKontext"
import { Shell } from "../Shell"
import type { SeitenName } from "../navigation"
import { TITEL } from "../navigation"
import { SeiteFuer } from "./register"
import { beispielApi } from "../tests/beispieldaten"
import { bildschirmbreiteSetzen } from "../tests/aufbau"
const ALLE_SEITEN = Object.keys(TITEL) as SeitenName[]
function zeige(name: SeitenName, id?: string) {
const api = beispielApi()
return render(
<div className="ads-root" data-theme="nacht">
<DatenAnbieter api={api}>
<Shell
route={id === undefined ? { name } : { name, id }}
geheZu={() => {}}
tabBeschriftung
>
<SeiteFuer
route={id === undefined ? { name } : { name, id }}
geheZu={() => {}}
tabBeschriftung
setzeTabBeschriftung={() => {}}
beiAbmeldung={() => {}}
/>
</Shell>
</DatenAnbieter>
</div>,
)
}
describe("Alle Seiten rendern mit Beispieldaten", () => {
beforeEach(() => {
bildschirmbreiteSetzen(390)
})
for (const name of ALLE_SEITEN) {
it(`${name} (${TITEL[name]})`, async () => {
const id = name === "trip" ? "t1" : name === "fill" ? "f1" : undefined
const { container } = zeige(name, id)
// Nach der Erstladung darf kein Ladehinweis mehr stehen.
await waitFor(() => {
expect(container.querySelector(".dm-laden")).toBeNull()
})
expect(container.textContent?.length ?? 0).toBeGreaterThan(20)
})
}
it("rendert jede Seite auch im Flächenlayout", async () => {
bildschirmbreiteSetzen(1280)
for (const name of ALLE_SEITEN) {
const id = name === "trip" ? "t1" : name === "fill" ? "f1" : undefined
const { container, unmount } = zeige(name, id)
await waitFor(() => {
expect(container.querySelector(".dm-laden")).toBeNull()
})
expect(container.querySelector(".dm-seitenleiste")).not.toBeNull()
unmount()
}
})
})
describe("Inhalte kommen wirklich aus den Daten", () => {
beforeEach(() => {
bildschirmbreiteSetzen(390)
})
it("zeigt Kilometerstand und Reichweite auf der Übersicht", async () => {
zeige("home")
await waitFor(() => expect(screen.getByText("48.250")).toBeTruthy())
expect(screen.getByText("385")).toBeTruthy()
})
it("zeigt einen unbekannten Prüfpunkt als unbekannt, nicht als sicher", async () => {
const { container } = zeige("sicherheit")
await waitFor(() => expect(container.querySelector(".dm-pruefliste")).not.toBeNull())
expect(container.querySelectorAll(".dm-punkt--unbekannt").length).toBe(1)
expect(screen.getByText("unbekannt")).toBeTruthy()
})
it("gruppiert Fahrten nach Jahr", async () => {
zeige("trips")
await waitFor(() => expect(screen.getByText("2026")).toBeTruthy())
expect(screen.getByText("42,5 km")).toBeTruthy()
})
it("zeigt bei einer Fahrt ohne Position keine erfundene Route", async () => {
const { container } = zeige("trip", "t1")
await waitFor(() => expect(screen.getByText("Keine Route aufgezeichnet")).toBeTruthy())
expect(container.querySelector(".dm-karte")).toBeNull()
})
it("rechnet den Tankvorgang mit mengengewichtetem Preis", async () => {
zeige("fill", "f1")
await waitFor(() => expect(screen.getByText("48,70")).toBeTruthy())
expect(screen.getByText("1,83 €")).toBeTruthy()
})
})
+81
View File
@@ -487,3 +487,84 @@
.dm-fussnote--warnung {
color: var(--warn);
}
/* --------------------------------------------------------- Listen, Text */
.dm-liste {
list-style: none;
margin: var(--sp-2) 0 0;
padding: 0;
font-size: 13.5px;
font-weight: 300;
line-height: 1.7;
}
.dm-liste li {
padding-left: 14px;
position: relative;
}
.dm-liste li::before {
content: "";
position: absolute;
left: 0;
top: 0.72em;
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--fg3);
}
.dm-liste__serie {
color: var(--fg2);
}
.dm-diagramm {
width: 100%;
height: 120px;
margin: var(--sp-3) 0;
display: block;
}
.dm-knopfreihe {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
margin-top: var(--sp-4);
}
.dm-waehlen {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
margin-top: var(--sp-3);
}
.dm-waehlen__knopf {
display: inline-flex;
align-items: center;
padding: 10px 16px;
border: 1px solid var(--line-strong);
border-radius: var(--r-pill);
font-size: 13.5px;
font-weight: 300;
color: var(--fg);
text-decoration: none;
}
.dm-waehlen__knopf:hover {
background: var(--shade);
}
/* Bleibt beim Scrollen unten stehen, damit ungespeicherte Aenderungen nicht
aus dem Blick geraten. */
.dm-speicherleiste {
position: sticky;
bottom: 0;
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3) 0;
background: var(--canvas);
border-top: 1px solid var(--line);
}
+213
View File
@@ -0,0 +1,213 @@
/**
* Beispieldaten für Rendertests. Aufgebaut wie das, was das Backend
* tatsächlich liefert (an der laufenden Testinstanz abgelesen), nicht wie es
* laut Spezifikation aussehen sollte.
*/
import type { DataMetricApi, Fahrt, Fahrzeugstatus, Profil, Tankvorgang } from "../api"
export const beispielStatus: Fahrzeugstatus = {
km: 48250,
tankprozent: 62,
reichweite_km: 385,
batteriespannung: null,
gesichert: true,
sicherheitscheck: [
{ label: "Tür vorne links", ok: true },
{ label: "Tür vorne rechts", ok: true },
{ label: "Fenster vorne links", ok: false },
{ label: "Motorhaube", ok: null },
],
oelwechsel_faellig_ts: "2027-03-14T00:00:00+00:00",
oelwechsel_faellig_km: 11750,
inspektion_faellig_ts: "2027-03-14T00:00:00+00:00",
inspektion_faellig_km: 11750,
}
export const beispielProfil: Profil = {
fahrzeug: {
fin: "WUAZZZF48PA902804",
titel: "RS 4 Avant",
zusatz: "competition",
untertitel: "quattro · 331 kW",
details: "2.9 TFSI quattro competition",
kennzeichen: "M AB 1234",
erstzulassung: "2023-04-01",
hauptuntersuchung_faellig: "2027-04-01",
tankvolumen_liter: 58,
wlan_name: "Audi_MMI",
ausfuehrung: "competition",
},
einstellungen: {
nacht_von: "22:00",
nacht_bis: "06:00",
fahrten_pausenzeit_min: 15,
uebersichtsbild: "seitenansicht.webp",
kraftstoffanbieter: "Shell",
smartdeal: { aktiv: true, laeuft_ab: null },
oelwechsel_intervall: {
modus: "hersteller",
km: 15000,
monate: 12,
hersteller_km: 30000,
hersteller_monate: 24,
},
backup_intervall: "taeglich",
letztes_backup: "2026-08-10T04:00:00",
},
versicherung: {
gesellschaft: "Beispiel Versicherung",
vertrag: "Vollkasko",
nummer: "V-123456",
service: "0800 111111",
notfall: "0800 222222",
beitrag: 840,
zahlweise: "jährlich",
gueltigAb: "2026-01-01",
teile: [
{ bez: "Haftpflicht", betrag: 320 },
{ bez: "Vollkasko", betrag: 520 },
],
selbst: [["Vollkasko", "500 €"]],
leistungen: [{ bez: "Werkstattbindung", wert: "nein" }],
schutzbrief: {
stand: "2026-01",
gilt: "Europa",
versichert: "Fahrzeug und Insassen",
ausloeser: ["Panne", "Unfall"],
entweder: [],
gruppen: ["Abschleppen"],
nicht: [],
pflichten: ["Unverzüglich melden"],
},
},
steuer: {
betrag: 320,
zeitraum: "jährlich",
faellig: "2027-04-01",
grundlage: "Hubraum und CO₂-Ausstoß",
lastschrift: true,
finanzamt: "München",
},
reifen: {
aktiv: "sommer",
anzugsmoment_nm: 140,
wechsel: { datum: "2026-10-15", vereinbart: false },
referenz_odo_km: 48250,
saetze: {
sommer: { km: 12000, mass: "275/30 R20", marke: "Michelin", druck_vorne: "2,5", druck_hinten: "2,4" },
winter: { km: 8000, mass: "265/35 R19", marke: "Continental", druck_vorne: "2,6", druck_hinten: "2,5" },
},
},
service: {
vereinbart: {},
autohaus: { name: "Audi Zentrum", adresse: "Musterweg 1", tel: "089 1234", mail: "a@b.de" },
buch: [
{ datum: "2026-02-10", km: 40000, art: "Ölwechsel", betrieb: "Audi Zentrum" },
{ datum: "2025-03-05", km: 25000, art: "Inspektion", betrieb: "Audi Zentrum" },
],
},
technik: [
{ gruppe: "Motor", posten: [["Bauart", "V6 Biturbo"], ["Leistung", "331 kW"]] },
],
ausstattung: [{ gruppe: "RS-Paket", sonder: ["RS competition"], serie: ["Klimaautomatik"] }],
}
export const beispielFahrten: Fahrt[] = [
{
trip_id: "t1",
ts_start: "2026-08-10T08:00:00+02:00",
ts_end: "2026-08-10T08:45:00+02:00",
duration_s: 2700,
distance_km: 42.5,
km_quelle: "odometer",
odo_start: 48200,
odo_end: 48242.5,
art: "arbeitsweg",
source: "ha",
status: "vollständig",
},
{
trip_id: "t2",
ts_start: "2026-08-09T23:10:00+02:00",
ts_end: "2026-08-09T23:40:00+02:00",
duration_s: 1800,
distance_km: 18.2,
km_quelle: "odometer",
odo_start: 48180,
odo_end: 48198.2,
art: "privat",
source: "manual",
status: "offen",
},
]
export const beispielTankvorgaenge: Tankvorgang[] = [
{
tank_id: "f1",
ts: "2026-08-08T17:20:00+02:00",
liters: 48.7,
fuel_total_eur: 89.12,
price_per_l: 1.83,
station_name: "Shell München Ost",
station_address: "Musterstraße 5",
product_name: "V-Power",
odometer_km: 48100,
distance_km: 520,
receipt_file: "beleg-2026-08-08.pdf",
receipt_no: "4711",
discount: 3.4,
source: "beleg",
status: "vollständig",
},
]
/** Datenschicht-Ersatz für Tests: liefert Beispieldaten, schreibt ins Leere. */
export function beispielApi(): DataMetricApi {
const horcher = { verbindung: [] as ((z: string) => void)[] }
return {
rest: {
zustandLesen: async () => ({
entity_id: "pyscript.audi_dashboard_fahrzeugstatus",
state: "aktuell",
attributes: { daten: beispielStatus },
last_changed: "2026-08-11T09:00:00+00:00",
last_updated: "2026-08-11T09:00:00+00:00",
}),
datenLesen: async () => [],
dienstAufrufen: async () => null,
verbindungPruefen: async () => ({ message: "API running." }),
zugangSetzen: () => {},
},
live: {
aufVerbindung: (h: (z: string) => void) => {
horcher.verbindung.push(h)
h("verbunden")
return () => {}
},
aufZustand: () => () => {},
starten: async () => {},
beenden: () => {},
},
warteschlange: {
laden: async () => {},
aufAenderung: (h: (a: unknown[]) => void) => {
h([])
return () => {}
},
anzahl: 0,
},
profilLesen: async () => beispielProfil,
fahrtenLesen: async () => beispielFahrten,
tankvorgaengeLesen: async () => beispielTankvorgaenge,
fahrzeugstatusLesen: async () => beispielStatus,
profilSchreiben: async () => null,
belegHochladen: async () => null,
fahrtAnlegen: async () => null,
fahrtLoeschen: async () => null,
tankvorgangAnlegen: async () => null,
tankvorgangAktualisieren: async () => null,
tankvorgangLoeschen: async () => null,
jetztAktualisieren: async () => null,
} as unknown as DataMetricApi
}