From 8e6e5bc2ee7e58003227d2a54ce3f4d8aa453d0a Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 11 Aug 2026 11:17:47 +0200 Subject: [PATCH] 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. --- companion-app/src/daten/service.ts | 96 +++++ companion-app/src/funktionen.ts | 17 + companion-app/src/screens/Batterie.tsx | 177 +++++++++ companion-app/src/screens/Einstellungen.tsx | 357 +++++++++++++++++ companion-app/src/screens/Fahrzeugdaten.tsx | 103 +++++ companion-app/src/screens/LiveFahrt.tsx | 87 +++++ companion-app/src/screens/Platzhalter.tsx | 50 --- companion-app/src/screens/Reifen.tsx | 157 ++++++++ companion-app/src/screens/Service.tsx | 311 +++++++++++++++ companion-app/src/screens/Versicherung.tsx | 404 ++++++++++++++++++++ companion-app/src/screens/csv.ts | 33 ++ companion-app/src/screens/kalender.ts | 64 ++++ companion-app/src/screens/register.tsx | 53 ++- companion-app/src/screens/screens.test.tsx | 109 ++++++ companion-app/src/stile/screens.css | 81 ++++ companion-app/src/tests/beispieldaten.ts | 213 +++++++++++ 16 files changed, 2258 insertions(+), 54 deletions(-) create mode 100644 companion-app/src/daten/service.ts create mode 100644 companion-app/src/funktionen.ts create mode 100644 companion-app/src/screens/Batterie.tsx create mode 100644 companion-app/src/screens/Einstellungen.tsx create mode 100644 companion-app/src/screens/Fahrzeugdaten.tsx create mode 100644 companion-app/src/screens/LiveFahrt.tsx delete mode 100644 companion-app/src/screens/Platzhalter.tsx create mode 100644 companion-app/src/screens/Reifen.tsx create mode 100644 companion-app/src/screens/Service.tsx create mode 100644 companion-app/src/screens/Versicherung.tsx create mode 100644 companion-app/src/screens/csv.ts create mode 100644 companion-app/src/screens/kalender.ts create mode 100644 companion-app/src/screens/screens.test.tsx create mode 100644 companion-app/src/tests/beispieldaten.ts diff --git a/companion-app/src/daten/service.ts b/companion-app/src/daten/service.ts new file mode 100644 index 0000000..7f4a965 --- /dev/null +++ b/companion-app/src/daten/service.ts @@ -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, + } +} diff --git a/companion-app/src/funktionen.ts b/companion-app/src/funktionen.ts new file mode 100644 index 0000000..4fecf52 --- /dev/null +++ b/companion-app/src/funktionen.ts @@ -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 diff --git a/companion-app/src/screens/Batterie.tsx b/companion-app/src/screens/Batterie.tsx new file mode 100644 index 0000000..d46dbb0 --- /dev/null +++ b/companion-app/src/screens/Batterie.tsx @@ -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(null) + const [fehler, setzeFehler] = useState(false) + + useEffect(() => { + void api.rest + .datenLesen(ENTITAETEN.batterieverlauf) + .then((daten) => setzeVerlauf(Array.isArray(daten) ? daten : [])) + .catch(() => setzeFehler(true)) + }, [api]) + + if (fehler) { + return ( + + ) + } + + if (!verlauf) return
Verlauf wird geladen …
+ + const messwerte = verlauf.filter((t) => t.min != null) + + if (messwerte.length === 0) { + return ( + + ) + } + + 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 ( + <> + + Ruhespannung +
+
+ {letzter.min != null ? de(letzter.min, 2) : "—"} + V +
+
+ + + + + = 0 ? "+" : ""}${de(trend, 2)} V` : "—"} + /> + + + } + /> +

+ 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. +

+
+ + ) +} + +/** 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 ( + + t.max)} fill="none" stroke="var(--fg3)" strokeWidth="1" /> + t.min)} fill="none" stroke="var(--red)" strokeWidth="1.5" /> + + ) +} diff --git a/companion-app/src/screens/Einstellungen.tsx b/companion-app/src/screens/Einstellungen.tsx new file mode 100644 index 0000000..31f876e --- /dev/null +++ b/companion-app/src/screens/Einstellungen.tsx @@ -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(null) + const [laeuft, setzeLaeuft] = useState(false) + const [gespeichert, setzeGespeichert] = useState(false) + const dateiwahl = useRef(null) + + if (!einstellungen || !fahrzeug) return null + const werte = entwurf ?? einstellungen + + const aendern = (teil: Partial) => + 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 ( + <> + + Fahrzeug + + aendern({ fahrzeugtitel: e.target.value })} + /> + + + aendern({ fahrzeugzusatz: e.target.value })} + /> + + + aendern({ kennzeichen: e.target.value })} + /> + + + aendern({ tankvolumen: Number(e.target.value) || 0 })} + /> + + + aendern({ wlanName: e.target.value })} + /> + + + + + Fahrten + + aendern({ pauseMin: Number(e.target.value) || 0 })} + /> + + + aendern({ nachtVon: e.target.value })} + /> + + + aendern({ nachtBis: e.target.value })} + /> + +

+ Setzt das Fahrzeug innerhalb der Pausenzeit die Fahrt fort, zählt das als eine Fahrt und + nicht als zwei — etwa bei einem Tankstopp. +

+
+ + + Darstellung + + themeSetzen(neu as "nacht" | "tag")} + /> + + + + + + + + Bild auf der Übersicht + + + +
+ {BILDPLAETZE.map((platz) => ( + + ))} +
+

+ Fotos lädst du über die Verwaltung im Home Assistant hoch. Die Dateinamen sind fest + vergeben — der Server nimmt nur diese an. +

+
+ + + Tankrabatt + + + void profilSpeichern({ + fahrzeug: { + ...fahrzeug, + smartdeal: { ...fahrzeug.smartdeal, aktiv: an }, + }, + }) + } + aria-label="Tankrabatt aktiv" + /> + + + + void profilSpeichern({ + fahrzeug: { + ...fahrzeug, + smartdeal: { ...fahrzeug.smartdeal, laeuftAb: e.target.value || null }, + }, + }) + } + /> + +

+ Das Datum gilt einschließlich — am Stichtag selbst zählt der Rabatt noch, erst am Folgetag + schaltet er ab. +

+
+ + + Sicherung + + + + + } + /> +
+ + void api.rest.dienstAufrufen("pyscript", "audi_dashboard_backup_jetzt") + } + > + Jetzt sichern + + Auf dieses Gerät laden +
+
+ + + Daten ausgeben +

+ Als CSV mit Semikolon und deutschem Zahlenformat — so öffnet Excel die Datei ohne + Nachfragen. +

+
+ + csvHerunterladen( + "fahrten", + ["Beginn", "Ende", "Strecke km", "Dauer s", "Art", "Status"], + fahrten.map((f) => [ + f.ts_start, + f.ts_end, + f.distance_km ?? "", + f.duration_s, + f.art, + f.status, + ]), + ) + } + > + Fahrten + + + csvHerunterladen( + "tankvorgaenge", + ["Zeitpunkt", "Liter", "Betrag", "Preis je Liter", "Tankstelle", "Kilometerstand"], + tankvorgaenge.map((t) => [ + t.ts, + t.liters ?? "", + t.fuel_total_eur ?? "", + t.price_per_l ?? "", + t.station_name ?? "", + t.odometer_km ?? "", + ]), + ) + } + > + Tankvorgänge + +
+
+ + + Zugang + + + + + } + /> +
+ void abmelden()}> + Verbindung trennen + +
+
+ + + + {gespeichert &&

Gespeichert.

} + {entwurf && ( +
+ void speichern()} disabled={laeuft}> + {laeuft ? "Speichere …" : "Änderungen speichern"} + + +
+ )} + + ) +} diff --git a/companion-app/src/screens/Fahrzeugdaten.tsx b/companion-app/src/screens/Fahrzeugdaten.tsx new file mode 100644 index 0000000..bee5242 --- /dev/null +++ b/companion-app/src/screens/Fahrzeugdaten.tsx @@ -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 ( + <> + + Identität + + + + + + + + + + } + /> + + + {technik.length > 0 && ( + + Technische Daten + {technik.map((gruppe, i) => ( + + ( + + ))} + /> + + ))} + + )} + + {ausstattung.length > 0 && ( + + Ausstattung + {ausstattung.map((gruppe, i) => ( + +
    + {(gruppe.sonder ?? []).map((posten) => ( +
  • {posten}
  • + ))} + {(gruppe.serie ?? []).map((posten) => ( +
  • + {posten} +
  • + ))} +
+
+ ))} +
+ )} + + ) +} diff --git a/companion-app/src/screens/LiveFahrt.tsx b/companion-app/src/screens/LiveFahrt.tsx new file mode 100644 index 0000000..a228a46 --- /dev/null +++ b/companion-app/src/screens/LiveFahrt.tsx @@ -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): 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) + + if (!live.faehrt) { + return ( + + ) + } + + return ( + <> + + Geschwindigkeit + + + + + + } + /> + + + + Position + {live.position ? ( + + ) : ( + + )} + + + ) +} diff --git a/companion-app/src/screens/Platzhalter.tsx b/companion-app/src/screens/Platzhalter.tsx deleted file mode 100644 index 9329746..0000000 --- a/companion-app/src/screens/Platzhalter.tsx +++ /dev/null @@ -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> = { - 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 ( - <> - -
{TITEL[route.name]}
-

- Diese Seite wird noch gebaut. -

-
- {weiter.length > 0 && ( -
- {weiter.map((ziel) => ( - geheZu(ziel)}> - weiter -
{TITEL[ziel]}
-
- ))} -
- )} - - ) -} diff --git a/companion-app/src/screens/Reifen.tsx b/companion-app/src/screens/Reifen.tsx new file mode 100644 index 0000000..2c3c248 --- /dev/null +++ b/companion-app/src/screens/Reifen.tsx @@ -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 ( + <> + + Montiert +
+
{aktiv}
+ void wechseln()} disabled={laeuft}> + {laeuft ? "Stelle um …" : `Auf ${aktiv === "Sommer" ? "Winter" : "Sommer"} wechseln`} + +
+ + + + + } + /> + + void wechseldatumSetzen(e.target.value)} + /> + + {wechsel.datum && ( +
+ + kalenderDateiLaden( + "Reifenwechsel", + wechsel.datum!, + `Wechsel auf ${aktiv === "Sommer" ? "Winter" : "Sommer"}reifen`, + ) + } + > + In den Kalender eintragen + +
+ )} +
+ + setzeGezeigt(wert as "Sommer" | "Winter")} + /> + + + + {anzeige}reifen {anzeige === aktiv ? "· montiert" : "· eingelagert"} + + + + + + + + + {Boolean(satz["kommentar"]) && ( + + )} + + } + /> +

+ Die gefahrene Strecke schreibt der Server bei jeder Änderung des Kilometerstands fort und + rechnet sie immer dem gerade montierten Satz zu. +

+
+ + ) +} diff --git a/companion-app/src/screens/Service.tsx b/companion-app/src/screens/Service.tsx new file mode 100644 index 0000000..55e7a86 --- /dev/null +++ b/companion-app/src/screens/Service.tsx @@ -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 ( + <> + + Nächster Ölwechsel + + + + + + + } + /> + {prognose && ( +
+ + kalenderDateiLaden("Ölwechsel", prognose.datum, "Ölwechsel laut eigener Prognose") + } + > + In den Kalender eintragen + +
+ )} +

+ 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. +

+
+ +
+ geheZu("werkstatt")} + /> + geheZu("sbuch")} + /> +
+ + ) +} + +export function Werkstatt() { + const { fahrzeug, profilSpeichern } = useDaten() + const autohausRoh = (fahrzeug?.service["autohaus"] ?? {}) as Autohaus + const [entwurf, setzeEntwurf] = useState({ ...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) => ( + + setzeEntwurf((alt) => ({ ...alt, [schluessel]: e.target.value }))} + /> + + ) + + return ( + + Werkstatt + {feld("name", "Name")} + {feld("adresse", "Adresse")} + {feld("tel", "Telefon")} + {feld("mail", "E-Mail", true)} + {gespeichert &&

Gespeichert.

} +
+ void speichern()} disabled={laeuft}> + {laeuft ? "Speichere …" : "Speichern"} + +
+
+ ) +} + +export function Servicebuch() { + const { fahrzeug, profilSpeichern } = useDaten() + const [neuOffen, setzeNeuOffen] = useState(false) + const [entwurf, setzeEntwurf] = useState({ 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 ( + <> +
+ {de(buch.length)} Einträge + +
+ + {neuOffen && ( + + Neuer Eintrag + + setzeEntwurf((alt) => ({ ...alt, datum: e.target.value }))} + /> + + + + + + setzeEntwurf((alt) => ({ ...alt, km: Number(e.target.value) }))} + /> + + + setzeEntwurf((alt) => ({ ...alt, betrieb: e.target.value }))} + /> + + + setzeEntwurf((alt) => ({ ...alt, notiz: e.target.value }))} + /> + +
+ void hinzufuegen()} disabled={laeuft}> + Speichern + +
+
+ )} + + {buch.length === 0 && !neuOffen && ( + setzeNeuOffen(true) }} + /> + )} + + {buch.map((eintrag, index) => ( + +
+
+ {eintrag.datum ? datum(eintrag.datum) : "ohne Datum"} +
+ {eintrag.art ?? "Eintrag"} +
+
+ +
+ + + + {eintrag.notiz && } + + } + /> +
+ ))} + + ) +} diff --git a/companion-app/src/screens/Versicherung.tsx b/companion-app/src/screens/Versicherung.tsx new file mode 100644 index 0000000..92b1f1b --- /dev/null +++ b/companion-app/src/screens/Versicherung.tsx @@ -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 ( + <> + + Versicherung + + + + + + + + } + /> + + +
+ geheZu("beitrag")} /> + geheZu("vertragsdetails")} + /> + geheZu("notruf")} + /> + geheZu("steuer")} + /> +
+ + ) +} + +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(() => 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 ( + + ) + } + + return ( + + Beitragsposten + {entwurf.map((posten, index) => ( + + + setzeEntwurf((alt) => + alt.map((p, i) => + i === index ? { ...p, betrag: Number(e.target.value.replace(",", ".")) } : p, + ), + ) + } + /> + + ))} + s + (Number(t.betrag) || 0), 0))} €`} + /> + } + /> + {gespeichert &&

Gespeichert.

} +
+ void speichern()} disabled={laeuft}> + {laeuft ? "Speichere …" : "Speichern"} + +
+
+ ) +} + +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 ( + <> + + Leistungen + {leistungen.length === 0 ? ( +

Im Profil sind noch keine Leistungen hinterlegt.

+ ) : ( + ( + + ))} + /> + )} +
+ + {selbst.length > 0 && ( + + Selbstbeteiligung + ( + + ))} + /> + + )} + + geheZu("schutz")} + /> + + ) +} + +export function Schutzbrief() { + const { fahrzeug } = useDaten() + if (!fahrzeug) return null + + const s = (fahrzeug.versicherung["schutzbrief"] ?? {}) as Record + 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 ( + + ) + } + + return ( + <> + + Schutzbrief + + + + + + } + /> + + {listen + .filter(([, eintraege]) => eintraege.length > 0) + .map(([titel, eintraege]) => ( + + {titel} +
    + {eintraege.map((eintrag) => ( +
  • {eintrag}
  • + ))} +
+
+ ))} + + ) +} + +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 ( + + Nummern im Notfall + + setzeService(e.target.value)} + /> + + + setzeNotfall(e.target.value)} + /> + + {(service || notfall) && ( +
+ {service && ( + + Service anrufen + + )} + {notfall && ( + + Notfall anrufen + + )} +
+ )} + {gespeichert &&

Gespeichert.

} +
+ void speichern()} disabled={laeuft}> + {laeuft ? "Speichere …" : "Speichern"} + +
+
+ ) +} + +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 ( + + Kfz-Steuer + + + setzeEntwurf((alt) => ({ ...alt, betrag: Number(e.target.value.replace(",", ".")) })) + } + /> + + + setzeEntwurf((alt) => ({ ...alt, zeitraum: e.target.value }))} + /> + + + setzeEntwurf((alt) => ({ ...alt, faellig: e.target.value }))} + /> + + + setzeEntwurf((alt) => ({ ...alt, finanzamt: e.target.value }))} + /> + + + + + + + } + /> + {gespeichert &&

Gespeichert.

} +
+ void speichern()} disabled={laeuft}> + {laeuft ? "Speichere …" : "Speichern"} + +
+

+ {de(1)} Fahrzeug, {(entwurf["zeitraum"] as string) || "jährlich"} zu zahlen. +

+
+ ) +} diff --git a/companion-app/src/screens/csv.ts b/companion-app/src/screens/csv.ts new file mode 100644 index 0000000..2f6de90 --- /dev/null +++ b/companion-app/src/screens/csv.ts @@ -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) +} diff --git a/companion-app/src/screens/kalender.ts b/companion-app/src/screens/kalender.ts new file mode 100644 index 0000000..c05865b --- /dev/null +++ b/companion-app/src/screens/kalender.ts @@ -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) +} diff --git a/companion-app/src/screens/register.tsx b/companion-app/src/screens/register.tsx index ca9b03c..5ff424c 100644 --- a/companion-app/src/screens/register.tsx +++ b/companion-app/src/screens/register.tsx @@ -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
Daten werden geladen …
} - 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 case "audi": return + case "ident": + return + case "battverlauf": + return + case "service": + return + case "werkstatt": + return + case "sbuch": + return + case "reifen": + return + case "vers": + return + case "beitrag": + return + case "vertragsdetails": + return + case "schutz": + return + case "notruf": + return + case "steuer": + return case "trips": return case "trip": @@ -59,7 +96,15 @@ export function SeiteFuer(props: SeitenProps) { return case "stat": return - default: - return + case "live": + return + case "einst": + return ( + + ) } } diff --git a/companion-app/src/screens/screens.test.tsx b/companion-app/src/screens/screens.test.tsx new file mode 100644 index 0000000..8eb0c9f --- /dev/null +++ b/companion-app/src/screens/screens.test.tsx @@ -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( +
+ + {}} + tabBeschriftung + > + {}} + tabBeschriftung + setzeTabBeschriftung={() => {}} + beiAbmeldung={() => {}} + /> + + +
, + ) +} + +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() + }) +}) diff --git a/companion-app/src/stile/screens.css b/companion-app/src/stile/screens.css index 611dc58..fa6c08f 100644 --- a/companion-app/src/stile/screens.css +++ b/companion-app/src/stile/screens.css @@ -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); +} diff --git a/companion-app/src/tests/beispieldaten.ts b/companion-app/src/tests/beispieldaten.ts new file mode 100644 index 0000000..f7addbb --- /dev/null +++ b/companion-app/src/tests/beispieldaten.ts @@ -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 +}