From 0103508ceacc22b77d833fa18cae23213ef3f8d4 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 11 Aug 2026 11:02:44 +0200 Subject: [PATCH] Phase 6: Datenanbindung, Ersteinrichtung und Offline-Anzeige Die App spricht jetzt echt mit Home Assistant. Ein Datenkontext buendelt die vier Datentoepfe, haengt sich an den WebSocket-Ereignisstrom und reicht Verbindungszustand und Warteschlange nach aussen; die Screens kennen weder REST noch WebSocket. Ersteinrichtung prueft die Zugangsdaten, bevor sie sie speichert, und unterscheidet in der Fehlermeldung zwischen abgelehntem Token und nicht erreichbarem Server - zwei Faelle, die voellig verschiedene Reaktionen verlangen. Zwei echte Fehler, die erst der Test gegen die laufende Instanz zutage brachte: 1. Die Typen der Datenschicht deklarierten tank_prozent, sicher_abgestellt und sicherheit. Das Backend schreibt tankprozent, gesichert und sicherheitscheck - die Oberflaeche haette still ueberall undefined gelesen, ohne dass irgendetwas fehlgeschlagen waere. 2. Der Profil-Adapter reichte lebende Verweise ins Rohprofil durch. Ein Formular haette damit den Rohstand mitveraendert und die Zusage gebrochen, den vom Backend fortgeschriebenen Reifen-Kilometerstand nie zu ueberschreiben. Abschnitte werden jetzt kopiert. Ausserdem zwei Parameter-Eigenschaften in der Datenschicht aufgeloest, weil Node sie im Strip-Modus nicht uebersetzt und genau darueber die Rauchtests laufen. Belegt durch neun Pruefungen gegen die laufende Instanz (Lesen, Dienstaufruf, vollstaendiger Warteschlangenumlauf, WebSocket-Anmeldung) und 21 Unit-Tests. --- companion-app/scripts/smoke-auth.ts | 189 ++++++++++++++ companion-app/src/App.tsx | 78 ++++-- companion-app/src/api/rest.ts | 14 +- companion-app/src/api/types.ts | 16 +- companion-app/src/api/warteschlange.ts | 8 +- companion-app/src/daten/DatenKontext.tsx | 213 ++++++++++++++++ companion-app/src/daten/profilAdapter.test.ts | 158 ++++++++++++ companion-app/src/daten/profilAdapter.ts | 241 ++++++++++++++++++ companion-app/src/format.ts | 103 ++++++++ companion-app/src/screens/Einrichtung.tsx | 112 ++++++++ companion-app/src/screens/Hinweisleiste.tsx | 48 ++++ companion-app/src/screens/register.tsx | 36 +++ companion-app/src/stile/screens.css | 167 ++++++++++++ 13 files changed, 1358 insertions(+), 25 deletions(-) create mode 100644 companion-app/scripts/smoke-auth.ts create mode 100644 companion-app/src/daten/DatenKontext.tsx create mode 100644 companion-app/src/daten/profilAdapter.test.ts create mode 100644 companion-app/src/daten/profilAdapter.ts create mode 100644 companion-app/src/format.ts create mode 100644 companion-app/src/screens/Einrichtung.tsx create mode 100644 companion-app/src/screens/Hinweisleiste.tsx create mode 100644 companion-app/src/screens/register.tsx create mode 100644 companion-app/src/stile/screens.css diff --git a/companion-app/scripts/smoke-auth.ts b/companion-app/scripts/smoke-auth.ts new file mode 100644 index 0000000..f8b11fc --- /dev/null +++ b/companion-app/scripts/smoke-auth.ts @@ -0,0 +1,189 @@ +/* Rauchtest mit echtem Zugangstoken gegen die laufende HA-Testinstanz. + Ergänzt scripts/smoke.ts um alles, was ohne Anmeldung nicht prüfbar ist: + echte Lesevorgänge, ein echter Dienstaufruf und ein vollständiger Umlauf + durch die Warteschlange. + + Zugangsdaten kommen aus companion-app/.env.local (schreibt + testumgebung/aufsetzen.sh). Ohne sie bricht der Test mit einem Hinweis ab, + ohne zu scheitern - damit er in einer Umgebung ohne Testinstanz nicht + fälschlich rot wird. + + Aufruf: npm run smoke:auth */ + +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { dirname, resolve } from "node:path" + +import { ENTITAETEN } from "../src/api/types.ts" +import { HassRest } from "../src/api/rest.ts" +import { Warteschlange } from "../src/api/warteschlange.ts" +import { ablageSetzen, websocketUrl, type Ablage } from "../src/api/umgebung.ts" + +/* ------------------------------------------------------ Zugangsdaten */ + +const hier = dirname(fileURLToPath(import.meta.url)) +const umgebungsdatei = resolve(hier, "../.env.local") + +function umgebungLesen(): Record { + try { + const inhalt = readFileSync(umgebungsdatei, "utf8") + const werte: Record = {} + for (const zeile of inhalt.split("\n")) { + const treffer = /^([A-Z_]+)=(.*)$/.exec(zeile.trim()) + if (treffer?.[1] && treffer[2] !== undefined) werte[treffer[1]] = treffer[2] + } + return werte + } catch { + return {} + } +} + +const umgebung = umgebungLesen() +const BASIS = umgebung["VITE_TEST_URL"] ?? "http://localhost:18123" +const TOKEN = umgebung["VITE_TEST_TOKEN"] ?? process.env["VITE_TEST_TOKEN"] ?? "" + +if (!TOKEN) { + console.log("übersprungen: kein Token in companion-app/.env.local") + console.log(" testumgebung/aufsetzen.sh ausführen, dann erneut versuchen.") + process.exit(0) +} + +/* ------------------------------------------------------------ Rahmen */ + +let fehler = 0 +function pruefe(bezeichnung: string, bedingung: boolean, zusatz = ""): void { + console.log(`${bedingung ? "PASS" : "FAIL"} ${bezeichnung}${zusatz ? ` (${zusatz})` : ""}`) + if (!bedingung) fehler += 1 +} + +/** Ablage im Arbeitsspeicher — Node hat kein localStorage. */ +class SpeicherAblage implements Ablage { + #werte = new Map() + async lesen(schluessel: string) { + return this.#werte.get(schluessel) ?? null + } + async schreiben(schluessel: string, wert: string) { + this.#werte.set(schluessel, wert) + } + async loeschen(schluessel: string) { + this.#werte.delete(schluessel) + } +} +ablageSetzen(new SpeicherAblage()) + +const zugang = { basisUrl: BASIS, token: TOKEN } +const rest = new HassRest(zugang) + +/* ------------------------------------------------------------- Lesen */ + +try { + const antwort = await rest.verbindungPruefen() + pruefe("verbindungPruefen mit Token", typeof antwort.message === "string", antwort.message) +} catch (e) { + pruefe("verbindungPruefen mit Token", false, e instanceof Error ? e.message : String(e)) +} + +try { + const profil = await rest.datenLesen>(ENTITAETEN.profil) + pruefe( + "Profil liefert die erwarteten Abschnitte", + Boolean(profil["fahrzeug"] && profil["einstellungen"] && profil["reifen"]), + Object.keys(profil).join(", "), + ) +} catch (e) { + pruefe("Profil lesen", false, e instanceof Error ? e.message : String(e)) +} + +try { + const status = await rest.datenLesen>(ENTITAETEN.fahrzeugstatus) + /* Die Feldnamen sind der Grund für diesen Test: ein früherer Entwurf der + Typen hatte tank_prozent/sicher_abgestellt/sicherheit angenommen, das + Backend schreibt aber tankprozent/gesichert/sicherheitscheck. */ + pruefe( + "Fahrzeugstatus benutzt die erwarteten Feldnamen", + "tankprozent" in status && "gesichert" in status && "sicherheitscheck" in status, + Object.keys(status).slice(0, 6).join(", "), + ) + pruefe( + "Sicherheitscheck liefert alle 16 Einzelprüfungen", + Array.isArray(status["sicherheitscheck"]) && (status["sicherheitscheck"] as unknown[]).length === 16, + `${(status["sicherheitscheck"] as unknown[] | undefined)?.length ?? 0} Punkte`, + ) +} catch (e) { + pruefe("Fahrzeugstatus lesen", false, e instanceof Error ? e.message : String(e)) +} + +for (const [name, id] of [ + ["Fahrten", ENTITAETEN.fahrten], + ["Tankvorgänge", ENTITAETEN.tankvorgaenge], +] as const) { + try { + const liste = await rest.datenLesen(id) + pruefe(`${name} sind eine Liste`, Array.isArray(liste), `${liste.length} Einträge`) + } catch (e) { + pruefe(`${name} lesen`, false, e instanceof Error ? e.message : String(e)) + } +} + +/* -------------------------------------------------------- Dienstaufruf */ + +try { + await rest.dienstAufrufen("pyscript", "audi_dashboard_jetzt_aktualisieren") + pruefe("Dienstaufruf audi_dashboard_jetzt_aktualisieren", true) +} catch (e) { + pruefe("Dienstaufruf audi_dashboard_jetzt_aktualisieren", false, e instanceof Error ? e.message : String(e)) +} + +/* ------------------------------------------------------ Warteschlange */ + +try { + const warteschlange = new Warteschlange(rest) + await warteschlange.einreihen("pyscript", "audi_dashboard_jetzt_aktualisieren", {}, "Rauchtest") + + /* einreihen() stößt die Abarbeitung selbst an, ohne sie abzuwarten - ein + sofortiger eigener abarbeiten()-Aufruf liefe deshalb ins Leere (der Lauf + ist schon aktiv). Also warten, bis die Schlange tatsächlich leer ist. */ + const frist = Date.now() + 10_000 + while (warteschlange.anzahl > 0 && Date.now() < frist) { + await new Promise((weiter) => setTimeout(weiter, 100)) + } + pruefe( + "Warteschlange arbeitet einen Auftrag vollständig ab", + warteschlange.anzahl === 0, + `${warteschlange.anzahl} verblieben`, + ) +} catch (e) { + pruefe("Warteschlangen-Umlauf", false, e instanceof Error ? e.message : String(e)) +} + +/* ----------------------------------------- WebSocket mit Anmeldung */ + +try { + const ergebnis = await new Promise((aufloesen, ablehnen) => { + const ws = new WebSocket(websocketUrl(BASIS)) + const frist = setTimeout(() => { + ablehnen(new Error("Zeitüberschreitung")) + ws.close() + }, 10_000) + ws.onmessage = (nachricht) => { + const daten = JSON.parse(String(nachricht.data)) as { type: string } + if (daten.type === "auth_required") { + ws.send(JSON.stringify({ type: "auth", access_token: TOKEN })) + } else if (daten.type === "auth_ok" || daten.type === "auth_invalid") { + clearTimeout(frist) + aufloesen(daten.type) + ws.close() + } + } + ws.onerror = () => { + clearTimeout(frist) + ablehnen(new Error("WebSocket-Fehler")) + } + }) + pruefe("WebSocket meldet sich mit dem Token an", ergebnis === "auth_ok", ergebnis) +} catch (e) { + pruefe("WebSocket-Anmeldung", false, e instanceof Error ? e.message : String(e)) +} + +console.log(fehler === 0 ? "\nAlle Prüfungen bestanden." : `\n${fehler} Prüfung(en) fehlgeschlagen.`) +process.exitCode = fehler === 0 ? 0 : 1 diff --git a/companion-app/src/App.tsx b/companion-app/src/App.tsx index de95661..082fe9a 100644 --- a/companion-app/src/App.tsx +++ b/companion-app/src/App.tsx @@ -1,35 +1,81 @@ -import { useCallback, useState } from "react" +import { useCallback, useEffect, useMemo, useState } from "react" +import { DataMetricApi, zugangLesen } from "./api" +import { DatenAnbieter, useDaten } from "./daten/DatenKontext" import { Shell } from "./Shell" import type { Route, SeitenName } from "./navigation" import { useTabBeschriftung, useTheme } from "./theme" -import { Platzhalter } from "./screens/Platzhalter" +import { Einrichtung } from "./screens/Einrichtung" +import { Hinweisleiste } from "./screens/Hinweisleiste" +import { SeiteFuer } from "./screens/register" import "./stile/rahmen.css" +import "./stile/screens.css" + +type Startzustand = "pruefen" | "einrichten" | "bereit" /** - * Wurzel der App. Hält den Navigationszustand und das Theme; alles Weitere - * hängt darunter. Das äußerste Element trägt `ads-root` und `data-theme` — - * ohne diese Wurzelklasse greifen die Design-Tokens nicht - * (design-system/.design-sync/conventions.md). + * Wurzel der App. Entscheidet zuerst, ob Zugangsdaten vorliegen — ohne sie + * gibt es nichts anzuzeigen außer der Ersteinrichtung. Danach hängt alles + * unter dem Datenkontext. */ export function App() { - const [route, setzeRoute] = useState({ name: "home" }) const [theme] = useTheme() - const [tabBeschriftung] = useTabBeschriftung() + const [start, setzeStart] = useState("pruefen") - const geheZu = useCallback((name: SeitenName, id?: string) => { - setzeRoute(id === undefined ? { name } : { name, id }) - // Beim Seitenwechsel immer oben anfangen — sonst erbt die neue Seite die - // Scrollposition der alten. - document.querySelector(".dm-inhalt")?.scrollTo({ top: 0 }) + // Genau eine Instanz für die Lebensdauer der App: sie hält die + // WebSocket-Verbindung und die Warteschlange. + const api = useMemo(() => new DataMetricApi(), []) + + useEffect(() => { + void zugangLesen().then((zugang) => setzeStart(zugang ? "bereit" : "einrichten")) }, []) return (
- - - + {start === "pruefen" &&
Einen Moment …
} + {start === "einrichten" && setzeStart("bereit")} />} + {start === "bereit" && ( + + setzeStart("einrichten")} /> + + )}
) } + +function AngemeldeteApp({ beiAbmeldung }: { beiAbmeldung: () => void }) { + const [route, setzeRoute] = useState({ name: "home" }) + const [tabBeschriftung, setzeTabBeschriftung] = useTabBeschriftung() + const daten = useDaten() + + const geheZu = useCallback((name: SeitenName, id?: string) => { + setzeRoute(id === undefined ? { name } : { name, id }) + document.querySelector(".dm-inhalt")?.scrollTo({ top: 0 }) + }, []) + + // Ein abgelaufener oder zurückgezogener Token führt zurück in die + // Ersteinrichtung statt in eine endlose Fehlerschleife. + useEffect(() => { + if (daten.verbindung === "nicht_angemeldet") beiAbmeldung() + }, [daten.verbindung, beiAbmeldung]) + + return ( + + } + > + + + ) +} diff --git a/companion-app/src/api/rest.ts b/companion-app/src/api/rest.ts index 2cdc2a5..d771468 100644 --- a/companion-app/src/api/rest.ts +++ b/companion-app/src/api/rest.ts @@ -12,13 +12,17 @@ import type { DatenEntity, HassState } from "./types.ts"; import { zugangLesen, type Zugang } from "./umgebung.ts"; export class ApiFehler extends Error { - constructor( - message: string, - readonly status: number | null, - readonly ursache?: unknown, - ) { + /* Eigene Felder statt Parameter-Eigenschaften: Node kann letztere im + Strip-Modus (--experimental-strip-types) nicht übersetzen, und darüber + laufen die Rauchtests dieses Pakets. */ + readonly status: number | null; + readonly ursache: unknown; + + constructor(message: string, status: number | null, ursache?: unknown) { super(message); this.name = "ApiFehler"; + this.status = status; + this.ursache = ursache; } /** Fehlt oder taugt das Token nicht mehr - dann muss die Oberfläche zurück diff --git a/companion-app/src/api/types.ts b/companion-app/src/api/types.ts index 7db01b3..598cd47 100644 --- a/companion-app/src/api/types.ts +++ b/companion-app/src/api/types.ts @@ -94,12 +94,22 @@ export interface SicherheitsPunkt { ok: boolean | null; } +/** Feldnamen an der laufenden Instanz abgelesen, nicht aus der Spezifikation + abgeleitet: frontend_veroeffentlichung.py schreibt "tankprozent", + "gesichert" und "sicherheitscheck" — ein früherer Entwurf dieser Datei + hatte "tank_prozent"/"sicher_abgestellt"/"sicherheit" angenommen, womit + die Oberfläche still überall undefined gelesen hätte. */ export interface Fahrzeugstatus { km?: number | null; - tank_prozent?: number | null; + tankprozent?: number | null; reichweite_km?: number | null; - sicher_abgestellt?: boolean | null; - sicherheit?: SicherheitsPunkt[]; + batteriespannung?: number | null; + gesichert?: boolean | null; + sicherheitscheck?: SicherheitsPunkt[]; + oelwechsel_faellig_ts?: string | null; + oelwechsel_faellig_km?: number | null; + inspektion_faellig_ts?: string | null; + inspektion_faellig_km?: number | null; [weitere: string]: unknown; } diff --git a/companion-app/src/api/warteschlange.ts b/companion-app/src/api/warteschlange.ts index f841a8b..a46ad5e 100644 --- a/companion-app/src/api/warteschlange.ts +++ b/companion-app/src/api/warteschlange.ts @@ -41,8 +41,14 @@ export class Warteschlange { #geladen = false; #laeuft = false; #horcher = new Set(); + /* Bewusst als eigenes Feld statt als Parameter-Eigenschaft im Konstruktor: + Node kann Parameter-Eigenschaften im Strip-Modus (--experimental-strip-types) + nicht übersetzen, und genau darüber laufen die Rauchtests dieses Pakets. */ + readonly rest: HassRest; - constructor(private readonly rest: HassRest) {} + constructor(rest: HassRest) { + this.rest = rest; + } async laden(): Promise { if (this.#geladen) return; diff --git a/companion-app/src/daten/DatenKontext.tsx b/companion-app/src/daten/DatenKontext.tsx new file mode 100644 index 0000000..1c890e1 --- /dev/null +++ b/companion-app/src/daten/DatenKontext.tsx @@ -0,0 +1,213 @@ +/** + * Ein Zugang zu allen Daten für die gesamte Oberfläche. + * + * Hält die vier Datentöpfe (Profil, Fahrten, Tankvorgänge, Fahrzeugstatus), + * lauscht über die WebSocket-Verbindung auf Änderungen und reicht den + * Verbindungszustand sowie die Warteschlange nach außen. Die Screens kennen + * weder REST noch WebSocket — sie lesen aus diesem Kontext. + */ + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react" + +import { + ApiFehler, + DataMetricApi, + ENTITAETEN, + type Fahrt, + type Fahrzeugstatus, + type Profil, + type Tankvorgang, + type Verbindungszustand, + type WartenderAuftrag, +} from "../api" +import type { Einstellungen, Fahrzeug } from "./profilAdapter" +import { profilZuEinstellungen, profilZuFahrzeug, zusammenfuehren } from "./profilAdapter" + +export interface DatenWert { + bereit: boolean + /** Erste Ladung fehlgeschlagen und noch nie Daten gesehen. */ + ladefehler: string | null + verbindung: Verbindungszustand + warteschlange: readonly WartenderAuftrag[] + /** Zeitpunkt der letzten Statusmeldung — treibt die Veraltet-Anzeige. */ + statusStand: string | null + + einstellungen: Einstellungen | null + fahrzeug: Fahrzeug | null + fahrten: readonly Fahrt[] + tankvorgaenge: readonly Tankvorgang[] + rohprofil: Profil | null + + api: DataMetricApi + neuLaden: () => Promise + jetztAktualisieren: () => Promise + /** Speichert geänderte Einstellungen/Fahrzeugdaten als ganzes Profil. */ + profilSpeichern: (aenderung: { + einstellungen?: Einstellungen + fahrzeug?: Fahrzeug + }) => Promise +} + +const Kontext = createContext(null) + +export function useDaten(): DatenWert { + const wert = useContext(Kontext) + if (!wert) throw new Error("useDaten außerhalb von verwendet") + return wert +} + +export function DatenAnbieter({ + api, + children, +}: { + api: DataMetricApi + children: ReactNode +}) { + const [rohprofil, setzeRohprofil] = useState(null) + const [status, setzeStatus] = useState({}) + const [statusStand, setzeStatusStand] = useState(null) + const [fahrten, setzeFahrten] = useState([]) + const [tankvorgaenge, setzeTankvorgaenge] = useState([]) + const [bereit, setzeBereit] = useState(false) + const [ladefehler, setzeLadefehler] = useState(null) + const [verbindung, setzeVerbindung] = useState("getrennt") + const [warteschlange, setzeWarteschlange] = useState([]) + + // Damit profilSpeichern immer gegen den neuesten Rohstand arbeitet, auch + // wenn zwischendurch ein Push hereinkam. + const rohprofilRef = useRef(null) + rohprofilRef.current = rohprofil + + const neuLaden = useCallback(async () => { + try { + const [p, f, t, s] = await Promise.all([ + api.profilLesen(), + api.fahrtenLesen(), + api.tankvorgaengeLesen(), + api.rest.zustandLesen<{ daten: Fahrzeugstatus }>(ENTITAETEN.fahrzeugstatus), + ]) + setzeRohprofil(p) + setzeFahrten(f ?? []) + setzeTankvorgaenge(t ?? []) + setzeStatus(s.attributes.daten ?? {}) + setzeStatusStand(s.last_updated) + setzeLadefehler(null) + setzeBereit(true) + } catch (fehler) { + // Kein Netz ist kein Ladefehler, solange schon Daten da sind — dann + // zeigt die App den zwischengespeicherten Stand mit Offline-Hinweis. + const netzproblem = fehler instanceof ApiFehler && fehler.istNetzproblem + if (!netzproblem || !bereit) { + setzeLadefehler(fehler instanceof Error ? fehler.message : String(fehler)) + } + } + }, [api, bereit]) + + // Erstladung plus Live-Verbindung. + useEffect(() => { + void neuLaden() + void api.warteschlange.laden() + + const abVerbindung = api.live.aufVerbindung((zustand) => { + setzeVerbindung(zustand) + // Nach einem Wiederverbinden kann während der Trennung etwas passiert + // sein, das kein Ereignis mehr ausgelöst hat. + if (zustand === "verbunden") void neuLaden() + }) + + const abZustand = api.live.aufZustand(({ entity_id, new_state }) => { + if (!new_state) return + const daten = (new_state.attributes as { daten?: unknown }).daten + switch (entity_id) { + case ENTITAETEN.profil: + setzeRohprofil((daten ?? null) as Profil | null) + break + case ENTITAETEN.fahrten: + setzeFahrten((daten ?? []) as Fahrt[]) + break + case ENTITAETEN.tankvorgaenge: + setzeTankvorgaenge((daten ?? []) as Tankvorgang[]) + break + case ENTITAETEN.fahrzeugstatus: + setzeStatus((daten ?? {}) as Fahrzeugstatus) + setzeStatusStand(new_state.last_updated) + break + default: + break + } + }) + + const abWarteschlange = api.warteschlange.aufAenderung(setzeWarteschlange) + void api.live.starten() + + return () => { + abVerbindung() + abZustand() + abWarteschlange() + api.live.beenden() + } + // Absichtlich nur an `api` gebunden: neuLaden ändert sich mit `bereit`, + // was hier sonst die Verbindung bei jeder Erstladung neu aufbauen würde. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [api]) + + const einstellungen = useMemo( + () => (rohprofil ? profilZuEinstellungen(rohprofil) : null), + [rohprofil], + ) + const fahrzeug = useMemo( + () => (rohprofil ? profilZuFahrzeug(rohprofil, status) : null), + [rohprofil, status], + ) + + const profilSpeichern = useCallback( + async (aenderung: { einstellungen?: Einstellungen; fahrzeug?: Fahrzeug }) => { + const roh = rohprofilRef.current + if (!roh) throw new Error("Kein Profil geladen — Speichern nicht möglich.") + const neu = zusammenfuehren( + roh, + aenderung.einstellungen ?? profilZuEinstellungen(roh), + aenderung.fahrzeug ?? profilZuFahrzeug(roh, status), + ) + // Sofort lokal übernehmen, damit die Oberfläche nicht auf den + // Rückweg über das Backend warten muss (die Warteschlange kann im + // Funkloch beliebig lange brauchen). + setzeRohprofil(neu) + await api.profilSchreiben(neu) + }, + [api, status], + ) + + const jetztAktualisieren = useCallback(async () => { + await api.jetztAktualisieren() + await neuLaden() + }, [api, neuLaden]) + + const wert: DatenWert = { + bereit, + ladefehler, + verbindung, + warteschlange, + statusStand, + einstellungen, + fahrzeug, + fahrten, + tankvorgaenge, + rohprofil, + api, + neuLaden, + jetztAktualisieren, + profilSpeichern, + } + + return {children} +} diff --git a/companion-app/src/daten/profilAdapter.test.ts b/companion-app/src/daten/profilAdapter.test.ts new file mode 100644 index 0000000..0a5095f --- /dev/null +++ b/companion-app/src/daten/profilAdapter.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest" + +import type { Profil } from "../api" +import { isoTag } from "../format" +import { profilZuEinstellungen, profilZuFahrzeug, zusammenfuehren } from "./profilAdapter" + +function tagVersetzt(tage: number): string { + const d = new Date() + d.setDate(d.getDate() + tage) + return isoTag(d) +} + +function beispielprofil(aenderung: Partial = {}): Profil { + return { + fahrzeug: { + fin: "WUAZZZF48PA902804", + titel: "RS 4 Avant", + zusatz: "competition", + untertitel: "quattro · 331 kW", + kennzeichen: "M AB 1234", + tankvolumen_liter: 58, + wlan_name: "Audi_MMI", + }, + einstellungen: { + uebersichtsbild: "seitenansicht.webp", + fahrten_pausenzeit_min: 15, + smartdeal: { aktiv: true, laeuft_ab: null }, + oelwechsel_intervall: { modus: "hersteller", hersteller_km: 30000, hersteller_monate: 24 }, + }, + reifen: { + aktiv: "sommer", + anzugsmoment_nm: 140, + saetze: { sommer: { km: 12000, marke: "Michelin" }, winter: { km: 8000 } }, + }, + steuer: { betrag_eur: 320 }, + ...aenderung, + } +} + +describe("profilZuEinstellungen", () => { + it("liest die Fahrzeug- und Einstellungsfelder", () => { + const e = profilZuEinstellungen(beispielprofil()) + expect(e.fahrzeugtitel).toBe("RS 4 Avant") + expect(e.kennzeichen).toBe("M AB 1234") + expect(e.tankvolumen).toBe(58) + expect(e.pauseMin).toBe(15) + }) + + it("fällt auf sinnvolle Werte zurück, wenn Abschnitte fehlen", () => { + const e = profilZuEinstellungen({}) + expect(e.tankvolumen).toBe(58) + expect(e.pauseMin).toBe(15) + expect(e.backupIntervall).toBe("aus") + }) +}) + +describe("SmartDeal-Gültigkeit", () => { + it("gilt ohne Ablaufdatum, solange der Schalter an ist", () => { + const f = profilZuFahrzeug(beispielprofil(), {}) + expect(f.smartdeal.aktiv).toBe(true) + }) + + it("gilt am Ablauftag noch — das Datum ist einschließlich", () => { + const profil = beispielprofil() + ;(profil.einstellungen as Record)["smartdeal"] = { + aktiv: true, + laeuft_ab: isoTag(), + } + expect(profilZuFahrzeug(profil, {}).smartdeal.aktiv).toBe(true) + }) + + it("gilt am Folgetag nicht mehr", () => { + const profil = beispielprofil() + ;(profil.einstellungen as Record)["smartdeal"] = { + aktiv: true, + laeuft_ab: tagVersetzt(-1), + } + expect(profilZuFahrzeug(profil, {}).smartdeal.aktiv).toBe(false) + }) + + it("bleibt aus, wenn der Schalter aus ist — auch bei gültigem Datum", () => { + const profil = beispielprofil() + ;(profil.einstellungen as Record)["smartdeal"] = { + aktiv: false, + laeuft_ab: tagVersetzt(30), + } + expect(profilZuFahrzeug(profil, {}).smartdeal.aktiv).toBe(false) + }) +}) + +describe("profilZuFahrzeug mit Fahrzeugstatus", () => { + it("übernimmt die Statuswerte unter den Namen des Backends", () => { + const f = profilZuFahrzeug(beispielprofil(), { + km: 48250, + tankprozent: 62, + reichweite_km: 385, + gesichert: true, + sicherheitscheck: [{ label: "Tür vorne links", ok: true }], + }) + expect(f.odo).toBe(48250) + expect(f.odoBekannt).toBe(true) + expect(f.tankPct).toBe(62) + expect(f.reichweite).toBe(385) + expect(f.gesichert).toBe(true) + expect(f.sicherheitscheck).toHaveLength(1) + }) + + it("unterscheidet unbekannten Kilometerstand von null", () => { + const f = profilZuFahrzeug(beispielprofil(), {}) + expect(f.odo).toBe(0) + expect(f.odoBekannt).toBe(false) + }) + + it("übersetzt den aktiven Reifensatz in die Anzeigeform", () => { + const profil = beispielprofil() + ;(profil.reifen as Record)["aktiv"] = "winter" + expect(profilZuFahrzeug(profil, {}).reifen.aktiv).toBe("Winter") + }) +}) + +describe("zusammenfuehren", () => { + it("schreibt geänderte Einstellungen zurück, ohne Unbekanntes zu verlieren", () => { + const roh = beispielprofil({ technik: { hubraum: "2.9 V6" } }) + const einstellungen = profilZuEinstellungen(roh) + const fahrzeug = profilZuFahrzeug(roh, {}) + einstellungen.kennzeichen = "M XY 9999" + + const neu = zusammenfuehren(roh, einstellungen, fahrzeug) + expect((neu.fahrzeug as Record)["kennzeichen"]).toBe("M XY 9999") + // Abschnitte, die die Oberfläche nicht anfasst, bleiben unverändert. + expect(neu.technik).toEqual({ hubraum: "2.9 V6" }) + }) + + it("schreibt den Reifen-Kilometerstand nie mit", () => { + // Das Backend (reifenzaehler.py) schreibt km bei jeder Änderung des + // Kilometerstands fort. Ein im Browser gehaltener älterer Stand darf das + // nicht überschreiben. + const roh = beispielprofil() + const fahrzeug = profilZuFahrzeug(roh, {}) + fahrzeug.reifen.sommer.km = 1 + fahrzeug.reifen.sommer.marke = "Continental" + + const neu = zusammenfuehren(roh, profilZuEinstellungen(roh), fahrzeug) + const saetze = (neu.reifen as Record)["saetze"] as Record< + string, + Record + > + expect(saetze["sommer"]?.["km"]).toBe(12000) + expect(saetze["sommer"]?.["marke"]).toBe("Continental") + }) + + it("macht aus einem leeren Steuerdatum null statt einer leeren Zeichenkette", () => { + const roh = beispielprofil() + const fahrzeug = profilZuFahrzeug(roh, {}) + const neu = zusammenfuehren(roh, profilZuEinstellungen(roh), fahrzeug) + expect((neu.steuer as Record)["faellig"]).toBeNull() + }) +}) diff --git a/companion-app/src/daten/profilAdapter.ts b/companion-app/src/daten/profilAdapter.ts new file mode 100644 index 0000000..75e0ddb --- /dev/null +++ b/companion-app/src/daten/profilAdapter.ts @@ -0,0 +1,241 @@ +/** + * Übersetzt das Fahrzeugprofil des Backends in die Form, mit der die + * Oberfläche arbeitet — und wieder zurück. + * + * Aus dem alten Panel portiert (`profilZuConfig`/`profilZuCar`/ + * `configCarZuProfil`). Die Rechenregeln sind unverändert übernommen; nur die + * Typen sind neu, weil das Panel ohne auskam. + */ + +import type { Fahrzeugstatus, Profil, SicherheitsPunkt } from "../api" +import { isoTag } from "../format" + +export interface Einstellungen { + fahrzeugtitel: string + fahrzeugzusatz: string + fahrzeuguntertitel: string + kennzeichen: string + startbild: string + tankvolumen: number + nachtVon: string + nachtBis: string + pauseMin: number + markenname: string + wlanName: string + backupIntervall: string + letztesBackup: string | null + ausfuehrung: string +} + +export interface Reifensatz { + km?: number | null + groesse?: string | null + marke?: string | null + modell?: string | null + dot?: string | null + profiltiefe_mm?: number | null + [weitere: string]: unknown +} + +export interface Fahrzeug { + fin: string + details: string + erstzulassung: string + hu: string + odo: number + odoBekannt: boolean + tankPct: number + reichweite: number | null + batteriespannung: number | null + gesichert: boolean | null + sicherheitscheck: SicherheitsPunkt[] + oelwechselFaelligTs: string | null + oelwechselFaelligKm: number | null + inspektionFaelligTs: string | null + inspektionFaelligKm: number | null + smartdeal: { aktiv: boolean; laeuftAb: string | null } + oel: { + modus: string + km: number | null + monate: number | null + herstellerKm: number | null + herstellerMonate: number | null + } + versicherung: Record + steuer: Record & { faellig: string } + reifen: { + aktiv: "Sommer" | "Winter" + anzugsmoment: number | null + wechsel: Record + sommer: Reifensatz + winter: Reifensatz + } + service: Record + technik: Record + ausstattung: unknown +} + +/** + * Liest einen Abschnitt des Profils **als Kopie**. + * + * Die Kopie ist der Punkt: Ohne sie bekämen die Screens lebende Verweise ins + * Rohprofil, und eine Bearbeitung im Formular würde den Rohstand still + * mitverändern. Damit wäre er als Vergleichsbasis wertlos — und die Zusage, + * den vom Backend fortgeschriebenen Reifen-Kilometerstand nie zu + * überschreiben, ließe sich nicht mehr halten. + */ +function abschnitt(profil: Profil, name: string): Record { + const wert = profil[name] + if (!wert || typeof wert !== "object") return {} + return structuredClone(wert) as Record +} + +function text(quelle: Record, feld: string, ersatz = ""): string { + const wert = quelle[feld] + return typeof wert === "string" ? wert : ersatz +} + +function zahl(quelle: Record, feld: string): number | null { + const wert = quelle[feld] + return typeof wert === "number" ? wert : null +} + +export function profilZuEinstellungen(profil: Profil): Einstellungen { + const fahrzeug = abschnitt(profil, "fahrzeug") + const einst = abschnitt(profil, "einstellungen") + return { + fahrzeugtitel: text(fahrzeug, "titel"), + fahrzeugzusatz: text(fahrzeug, "zusatz"), + fahrzeuguntertitel: text(fahrzeug, "untertitel"), + kennzeichen: text(fahrzeug, "kennzeichen"), + startbild: text(einst, "uebersichtsbild", "seitenansicht.webp"), + tankvolumen: zahl(fahrzeug, "tankvolumen_liter") ?? 58, + nachtVon: text(einst, "nacht_von", "22:00"), + nachtBis: text(einst, "nacht_bis", "06:00"), + pauseMin: zahl(einst, "fahrten_pausenzeit_min") ?? 15, + markenname: text(einst, "kraftstoffanbieter", "Shell"), + wlanName: text(fahrzeug, "wlan_name"), + backupIntervall: text(einst, "backup_intervall", "aus"), + letztesBackup: text(einst, "letztes_backup") || null, + ausfuehrung: text(fahrzeug, "ausfuehrung"), + } +} + +export function profilZuFahrzeug(profil: Profil, status: Fahrzeugstatus): Fahrzeug { + const fahrzeug = abschnitt(profil, "fahrzeug") + const einst = abschnitt(profil, "einstellungen") + // Bereits eine Kopie (siehe abschnitt()), darf also gefahrlos weitergereicht + // und von den Formularen bearbeitet werden. + const reifen = abschnitt(profil, "reifen") + const saetze = (reifen["saetze"] ?? {}) as Record + const smartdeal = (einst["smartdeal"] ?? {}) as Record + const oel = (einst["oelwechsel_intervall"] ?? {}) as Record + const steuer = abschnitt(profil, "steuer") + + const laeuftAb = typeof smartdeal["laeuft_ab"] === "string" ? (smartdeal["laeuft_ab"] as string) : null + + return { + fin: text(fahrzeug, "fin"), + details: text(fahrzeug, "details"), + erstzulassung: text(fahrzeug, "erstzulassung"), + hu: text(fahrzeug, "hauptuntersuchung_faellig"), + odo: status.km ?? 0, + odoBekannt: status.km !== null && status.km !== undefined, + tankPct: status.tankprozent ?? 0, + reichweite: status.reichweite_km ?? null, + batteriespannung: status.batteriespannung ?? null, + gesichert: status.gesichert ?? null, + sicherheitscheck: status.sicherheitscheck ?? [], + oelwechselFaelligTs: status.oelwechsel_faellig_ts ?? null, + oelwechselFaelligKm: status.oelwechsel_faellig_km ?? null, + inspektionFaelligTs: status.inspektion_faellig_ts ?? null, + inspektionFaelligKm: status.inspektion_faellig_km ?? null, + // „Gültig bis" ist einschließlich: der Rabatt gilt noch am Stichtag selbst, + // erst am Folgetag schaltet er ab. Unverändert aus dem alten Panel. + smartdeal: { + aktiv: smartdeal["aktiv"] === true && (!laeuftAb || laeuftAb >= isoTag()), + laeuftAb, + }, + oel: { + modus: text(oel, "modus", "hersteller"), + km: zahl(oel, "km"), + monate: zahl(oel, "monate"), + herstellerKm: zahl(oel, "hersteller_km"), + herstellerMonate: zahl(oel, "hersteller_monate"), + }, + versicherung: abschnitt(profil, "versicherung"), + steuer: { ...steuer, faellig: text(steuer, "faellig") }, + reifen: { + aktiv: reifen["aktiv"] === "winter" ? "Winter" : "Sommer", + anzugsmoment: zahl(reifen, "anzugsmoment_nm"), + wechsel: (reifen["wechsel"] ?? {}) as Record, + sommer: saetze["sommer"] ?? {}, + winter: saetze["winter"] ?? {}, + }, + service: abschnitt(profil, "service"), + technik: abschnitt(profil, "technik"), + // Als Einzige eine Liste statt eines Objekts, deshalb eigens kopiert. + ausstattung: profil["ausstattung"] ? structuredClone(profil["ausstattung"]) : [], + } +} + +/** + * Rückrichtung für `audi_dashboard_profil_schreiben`. Das Backend kennt keinen + * Dienst für einzelne Felder — es wird immer das ganze Profil geschrieben, + * deshalb wird der zuletzt gelesene Rohstand kopiert und nur das Geänderte + * überschrieben. + */ +export function zusammenfuehren( + rohprofil: Profil, + einstellungen: Einstellungen, + fahrzeug: Fahrzeug, +): Profil { + const p = structuredClone(rohprofil) as Profil + const f = (p["fahrzeug"] ??= {}) as Record + const e = (p["einstellungen"] ??= {}) as Record + const r = (p["reifen"] ??= {}) as Record + + f["titel"] = einstellungen.fahrzeugtitel + f["zusatz"] = einstellungen.fahrzeugzusatz + f["untertitel"] = einstellungen.fahrzeuguntertitel + f["kennzeichen"] = einstellungen.kennzeichen + f["wlan_name"] = einstellungen.wlanName + f["ausfuehrung"] = einstellungen.ausfuehrung + f["tankvolumen_liter"] = einstellungen.tankvolumen + f["fin"] = fahrzeug.fin + f["erstzulassung"] = fahrzeug.erstzulassung + f["hauptuntersuchung_faellig"] = fahrzeug.hu + + e["uebersichtsbild"] = einstellungen.startbild + e["fahrten_pausenzeit_min"] = einstellungen.pauseMin + e["backup_intervall"] = einstellungen.backupIntervall + e["nacht_von"] = einstellungen.nachtVon + e["nacht_bis"] = einstellungen.nachtBis + e["smartdeal"] = { aktiv: fahrzeug.smartdeal.aktiv, laeuft_ab: fahrzeug.smartdeal.laeuftAb } + e["oelwechsel_intervall"] = { + modus: fahrzeug.oel.modus, + km: fahrzeug.oel.km, + monate: fahrzeug.oel.monate, + hersteller_km: fahrzeug.oel.herstellerKm, + hersteller_monate: fahrzeug.oel.herstellerMonate, + } + + p["versicherung"] = fahrzeug.versicherung + p["steuer"] = { ...fahrzeug.steuer, faellig: fahrzeug.steuer.faellig || null } + p["service"] = fahrzeug.service + + r["aktiv"] = fahrzeug.reifen.aktiv.toLowerCase() + r["anzugsmoment_nm"] = fahrzeug.reifen.anzugsmoment + r["wechsel"] = fahrzeug.reifen.wechsel + const saetze = (r["saetze"] ??= {}) as Record + for (const name of ["sommer", "winter"] as const) { + // km bewusst NICHT mitschreiben: das Backend (reifenzaehler.py) schreibt + // den Wert bei jeder Kilometerstandsänderung fort. Ein im Browser + // gehaltener älterer Stand würde eine zwischenzeitliche Fortschreibung + // überschreiben. + const { km: _km, ...rest } = fahrzeug.reifen[name] + saetze[name] = { ...(saetze[name] ?? {}), ...rest } + } + + return p +} diff --git a/companion-app/src/format.ts b/companion-app/src/format.ts new file mode 100644 index 0000000..b8914ec --- /dev/null +++ b/companion-app/src/format.ts @@ -0,0 +1,103 @@ +/** + * Zahlen, Geld, Datum und Dauer im deutschen Format. Aus dem alten Panel + * übernommen (`de()`/`eur()`), damit App und Panel identisch formatieren. + */ + +export function de(wert: number | null | undefined, nachkomma = 0): string { + return Number(wert ?? 0).toLocaleString("de-DE", { + minimumFractionDigits: nachkomma, + maximumFractionDigits: nachkomma, + }) +} + +export function eur(wert: number | null | undefined): string { + return de(wert, 2) +} + +/** Wie de(), aber „—" statt „0" wenn nichts bekannt ist. */ +export function deOderStrich(wert: number | null | undefined, nachkomma = 0): string { + return wert === null || wert === undefined ? "—" : de(wert, nachkomma) +} + +/** Summe über eine Liste. */ +export function summe(liste: readonly T[], auswahl: (eintrag: T) => number): number { + return liste.reduce((zwischenstand, eintrag) => zwischenstand + (auswahl(eintrag) || 0), 0) +} + +/** + * Durchschnittspreis je Liter — immer mengengewichtet (Summe Kosten geteilt + * durch Summe Liter), nie als Mittel der Einzelpreise. Eine kleine Tankung zu + * hohem Preis darf den Schnitt nicht so stark heben wie eine große. + */ +export function schnittpreis( + liste: readonly { liters?: number | null; fuel_total_eur?: number | null }[], +): number { + const liter = summe(liste, (t) => t.liters ?? 0) + if (liter <= 0) return 0 + return summe(liste, (t) => t.fuel_total_eur ?? 0) / liter +} + +const DATUM_KURZ = new Intl.DateTimeFormat("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", +}) + +const DATUM_LANG = new Intl.DateTimeFormat("de-DE", { + day: "numeric", + month: "long", + year: "numeric", +}) + +const UHRZEIT = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit" }) + +const MONAT_JAHR = new Intl.DateTimeFormat("de-DE", { month: "long", year: "numeric" }) + +function alsDatum(wert: string | number | Date | null | undefined): Date | null { + if (wert === null || wert === undefined || wert === "") return null + const datum = wert instanceof Date ? wert : new Date(wert) + return Number.isNaN(datum.getTime()) ? null : datum +} + +export function datum(wert: string | number | Date | null | undefined): string { + const d = alsDatum(wert) + return d ? DATUM_KURZ.format(d) : "—" +} + +export function datumLang(wert: string | number | Date | null | undefined): string { + const d = alsDatum(wert) + return d ? DATUM_LANG.format(d) : "—" +} + +export function uhrzeit(wert: string | number | Date | null | undefined): string { + const d = alsDatum(wert) + return d ? UHRZEIT.format(d) : "—" +} + +export function datumZeit(wert: string | number | Date | null | undefined): string { + const d = alsDatum(wert) + return d ? `${DATUM_KURZ.format(d)}, ${UHRZEIT.format(d)} Uhr` : "—" +} + +export function monatJahr(wert: string | number | Date | null | undefined): string { + const d = alsDatum(wert) + return d ? MONAT_JAHR.format(d) : "—" +} + +/** Dauer in Sekunden als „2 h 14 min" bzw. „14 min". */ +export function dauer(sekunden: number | null | undefined): string { + if (!sekunden || sekunden <= 0) return "—" + const minutenGesamt = Math.round(sekunden / 60) + const stunden = Math.floor(minutenGesamt / 60) + const minuten = minutenGesamt % 60 + if (stunden === 0) return `${minuten} min` + return `${stunden} h ${String(minuten).padStart(2, "0")} min` +} + +/** ISO-Tagesdatum (YYYY-MM-DD) für Vergleiche und Eingabefelder. */ +export function isoTag(wert: string | number | Date | null | undefined = new Date()): string { + const d = alsDatum(wert) + if (!d) return "" + const versatz = d.getTimezoneOffset() * 60_000 + return new Date(d.getTime() - versatz).toISOString().slice(0, 10) +} diff --git a/companion-app/src/screens/Einrichtung.tsx b/companion-app/src/screens/Einrichtung.tsx new file mode 100644 index 0000000..76cf81c --- /dev/null +++ b/companion-app/src/screens/Einrichtung.tsx @@ -0,0 +1,112 @@ +/** + * Ersteinrichtung: Die App holt ihre Daten vom eigenen Server, also braucht + * sie einmalig dessen Adresse und ein Zugriffstoken. + * + * Entwurf: `design/DM360.dc.html`, Abschnitt `isSetup`. Fehlermeldungen sind + * bewusst nach Ursache getrennt — „Token abgelehnt" und „Server nicht + * erreichbar" verlangen völlig verschiedene Reaktionen des Benutzers. + */ + +import { useState } from "react" + +import { ActionButton, Feld, Tile } from "@audi-dash/ui" + +import { ApiFehler, HassRest, basisUrlNormalisieren, zugangSpeichern } from "../api" + +export function Einrichtung({ fertig }: { fertig: () => void }) { + const [adresse, setzeAdresse] = useState("") + const [token, setzeToken] = useState("") + const [laeuft, setzeLaeuft] = useState(false) + const [fehler, setzeFehler] = useState(null) + + const verbinden = async () => { + setzeFehler(null) + if (!adresse.trim()) { + setzeFehler("Bitte die Adresse deines Servers eintragen.") + return + } + if (!token.trim()) { + setzeFehler("Bitte den Zugangs-Token einfügen.") + return + } + + setzeLaeuft(true) + try { + const zugang = { basisUrl: basisUrlNormalisieren(adresse), token: token.trim() } + // Erst prüfen, dann speichern — sonst merkt sich die App Zugangsdaten, + // die nachweislich nicht funktionieren. + await new HassRest(zugang).verbindungPruefen() + await zugangSpeichern(zugang) + fertig() + } catch (ursache) { + if (ursache instanceof ApiFehler && ursache.istAnmeldeproblem) { + setzeFehler("Der Server hat den Token abgelehnt. Wurde er vollständig eingefügt?") + } else if (ursache instanceof ApiFehler && ursache.istNetzproblem) { + setzeFehler( + "Der Server ist unter dieser Adresse nicht erreichbar. Stimmen Adresse und Port, und ist das Gerät im richtigen Netz?", + ) + } else { + setzeFehler(ursache instanceof Error ? ursache.message : String(ursache)) + } + } finally { + setzeLaeuft(false) + } + } + + return ( +
+
+ DataMetric360 +

Willkommen

+

+ Die App holt ihre Daten von deinem eigenen Server. Trag einmal die Adresse ein und füge + deinen Zugangs-Token ein. Danach läuft alles von selbst. +

+ + + + setzeAdresse(e.target.value)} + /> + + + setzeToken(e.target.value)} + /> + + + + {fehler && ( +

+ {fehler} +

+ )} + +
+ void verbinden()} disabled={laeuft}> + {laeuft ? "Verbinde …" : "Verbinden"} + +
+ +

+ Den Token erzeugst du in Home Assistant unter „Profil → Sicherheit → Langlebige + Zugriffstoken". Deine Daten bleiben auf deinem Server. +

+
+
+ ) +} diff --git a/companion-app/src/screens/Hinweisleiste.tsx b/companion-app/src/screens/Hinweisleiste.tsx new file mode 100644 index 0000000..b3712ea --- /dev/null +++ b/companion-app/src/screens/Hinweisleiste.tsx @@ -0,0 +1,48 @@ +/** + * Offline-Hinweis und Warteschlange über der Kopfzeile. + * + * Entscheidung aus COMPANION_APP_ARCHITECTURE.md §3: Ohne Verbindung bleiben + * die zuletzt geladenen Daten sichtbar, sind aber erkennbar nicht aktuell — + * nie ein leerer Bildschirm und nie eine stille Veraltung. + */ + +import type { Verbindungszustand, WartenderAuftrag } from "../api" +import { SymbolWarnung } from "../symbole" + +export function Hinweisleiste({ + verbindung, + warteschlange, +}: { + verbindung: Verbindungszustand + warteschlange: readonly WartenderAuftrag[] +}) { + const offline = verbindung !== "verbunden" + const wartend = warteschlange.length + + // "verbindet" gleich beim Start nicht als Offline melden — das würde bei + // jedem App-Start kurz aufblitzen. + if (!offline && wartend === 0) return null + if (verbindung === "verbindet" && wartend === 0) return null + + const gescheitert = warteschlange.filter((a) => a.fehlversuche > 0).length + + return ( +
+ + + {offline + ? verbindung === "nicht_angemeldet" + ? "Nicht angemeldet — bitte Zugang prüfen." + : "Offline — angezeigte Daten sind der letzte bekannte Stand." + : null} + {offline && wartend > 0 ? " " : null} + {wartend > 0 + ? wartend === 1 + ? "Eine Änderung wartet auf Übertragung." + : `${wartend} Änderungen warten auf Übertragung.` + : null} + {gescheitert > 0 ? ` ${gescheitert} davon mit Fehlversuchen.` : null} + +
+ ) +} diff --git a/companion-app/src/screens/register.tsx b/companion-app/src/screens/register.tsx new file mode 100644 index 0000000..b64e02e --- /dev/null +++ b/companion-app/src/screens/register.tsx @@ -0,0 +1,36 @@ +/** + * Zuordnung Route → Seite. Eine Stelle, an der jede Seite eingetragen ist; + * die Shell weiß nichts über Inhalte, die Seiten nichts über Navigation. + */ + +import { useDaten } from "../daten/DatenKontext" +import type { Route, SeitenName } from "../navigation" +import { Platzhalter } from "./Platzhalter" + +export interface SeitenProps { + route: Route + geheZu: (name: SeitenName, id?: string) => void + tabBeschriftung: boolean + setzeTabBeschriftung: (an: boolean) => void + beiAbmeldung: () => void +} + +export function SeiteFuer(props: SeitenProps) { + const daten = useDaten() + + // Erste Ladung: nichts anzeigen außer einem Hinweis. Ein leeres Gerüst + // wäre schlimmer als ein ehrliches „lädt". + if (!daten.bereit) { + if (daten.ladefehler) { + return ( +
+
Daten nicht erreichbar
+

{daten.ladefehler}

+
+ ) + } + return
Daten werden geladen …
+ } + + return +} diff --git a/companion-app/src/stile/screens.css b/companion-app/src/stile/screens.css new file mode 100644 index 0000000..40db87f --- /dev/null +++ b/companion-app/src/stile/screens.css @@ -0,0 +1,167 @@ +/* + * Screen-übergreifende Bausteine: Eingabefelder, Hinweisleiste, + * Ersteinrichtung, Leerzustände, Ladeanzeige. + */ + +/* -------------------------------------------------------- Eingabefelder */ + +.dm-eingabe, +.dm-auswahl { + font: inherit; + font-weight: 300; + font-size: 16px; + color: var(--fg); + background: none; + border: 0; + text-align: right; + outline: none; + min-width: 120px; + max-width: 60%; + padding: 0; +} + +.dm-eingabe::placeholder { + color: var(--fg3); +} + +.dm-auswahl { + text-align: right; + appearance: none; + cursor: pointer; +} + +.dm-auswahl option { + color: var(--fg); + background: var(--tile); +} + +.dm-notiz { + font: inherit; + font-weight: 300; + font-size: 16px; + color: var(--fg); + background: var(--tile-2); + border: 0; + border-radius: var(--r-func); + padding: 10px 12px; + width: 100%; + min-height: 88px; + resize: vertical; + outline: none; +} + +/* --------------------------------------------------------- Hinweisleiste */ + +.dm-hinweis { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: 9px var(--sp-5); + padding-top: calc(env(safe-area-inset-top, 0px) + 9px); + font-size: 12.5px; + font-weight: 300; + color: var(--fg); + background: var(--tile-2); + border-bottom: 1px solid var(--line); +} + +.dm-hinweis--offline { + color: var(--warn); +} + +.dm-hinweis svg { + flex: none; +} + +/* -------------------------------------------------------- Ersteinrichtung */ + +.dm-einrichtung { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + overflow-y: auto; +} + +.dm-einrichtung__karte { + width: 100%; + max-width: 420px; +} + +.dm-einrichtung__titel { + font-size: 28px; + font-weight: 300; + letter-spacing: -0.02em; + margin: 10px 0 12px; +} + +.dm-einrichtung__text { + font-size: 14px; + font-weight: 300; + line-height: 1.55; + color: var(--fg2); + margin: 0 0 22px; + text-wrap: pretty; +} + +.dm-einrichtung__fehler { + font-size: 13px; + font-weight: 300; + line-height: 1.5; + color: var(--bad); + margin: 14px 0 0; +} + +.dm-einrichtung__knoepfe { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: var(--sp-4); +} + +.dm-einrichtung__hinweis { + font-size: 11.5px; + font-weight: 300; + line-height: 1.5; + color: var(--fg3); + margin: 18px 0 0; +} + +/* ------------------------------------------------- Leer- und Ladezustände */ + +.dm-leer { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: var(--sp-2); + padding: 44px 24px; + color: var(--fg2); +} + +.dm-leer__titel { + font-size: 16px; + font-weight: 300; + color: var(--fg); +} + +.dm-leer__text { + font-size: 13.5px; + font-weight: 300; + line-height: 1.55; + max-width: 34ch; + margin: 0; + text-wrap: pretty; +} + +.dm-laden { + display: flex; + align-items: center; + justify-content: center; + gap: var(--sp-2); + padding: 44px 24px; + color: var(--fg2); + font-size: 13.5px; + font-weight: 300; +}