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