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.
This commit is contained in:
Paul Nothaft
2026-08-11 11:02:44 +02:00
parent 5c0c2d1898
commit 0103508cea
13 changed files with 1358 additions and 25 deletions
+189
View File
@@ -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<string, string> {
try {
const inhalt = readFileSync(umgebungsdatei, "utf8")
const werte: Record<string, string> = {}
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<string, string>()
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<Record<string, unknown>>(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<Record<string, unknown>>(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<unknown[]>(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<string>((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
+62 -16
View File
@@ -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<Route>({ name: "home" })
const [theme] = useTheme()
const [tabBeschriftung] = useTabBeschriftung()
const [start, setzeStart] = useState<Startzustand>("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 (
<div className="ads-root dm-wurzel" data-theme={theme}>
<Shell route={route} geheZu={geheZu} tabBeschriftung={tabBeschriftung}>
<Platzhalter route={route} geheZu={geheZu} />
</Shell>
{start === "pruefen" && <div className="dm-laden">Einen Moment </div>}
{start === "einrichten" && <Einrichtung fertig={() => setzeStart("bereit")} />}
{start === "bereit" && (
<DatenAnbieter api={api}>
<AngemeldeteApp beiAbmeldung={() => setzeStart("einrichten")} />
</DatenAnbieter>
)}
</div>
)
}
function AngemeldeteApp({ beiAbmeldung }: { beiAbmeldung: () => void }) {
const [route, setzeRoute] = useState<Route>({ 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 (
<Shell
route={route}
geheZu={geheZu}
tabBeschriftung={tabBeschriftung}
hinweis={
<Hinweisleiste verbindung={daten.verbindung} warteschlange={daten.warteschlange} />
}
>
<SeiteFuer
route={route}
geheZu={geheZu}
tabBeschriftung={tabBeschriftung}
setzeTabBeschriftung={setzeTabBeschriftung}
beiAbmeldung={beiAbmeldung}
/>
</Shell>
)
}
+9 -5
View File
@@ -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
+13 -3
View File
@@ -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;
}
+7 -1
View File
@@ -41,8 +41,14 @@ export class Warteschlange {
#geladen = false;
#laeuft = false;
#horcher = new Set<WarteschlangenHorcher>();
/* 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<void> {
if (this.#geladen) return;
+213
View File
@@ -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<void>
jetztAktualisieren: () => Promise<void>
/** Speichert geänderte Einstellungen/Fahrzeugdaten als ganzes Profil. */
profilSpeichern: (aenderung: {
einstellungen?: Einstellungen
fahrzeug?: Fahrzeug
}) => Promise<void>
}
const Kontext = createContext<DatenWert | null>(null)
export function useDaten(): DatenWert {
const wert = useContext(Kontext)
if (!wert) throw new Error("useDaten außerhalb von <DatenAnbieter> verwendet")
return wert
}
export function DatenAnbieter({
api,
children,
}: {
api: DataMetricApi
children: ReactNode
}) {
const [rohprofil, setzeRohprofil] = useState<Profil | null>(null)
const [status, setzeStatus] = useState<Fahrzeugstatus>({})
const [statusStand, setzeStatusStand] = useState<string | null>(null)
const [fahrten, setzeFahrten] = useState<readonly Fahrt[]>([])
const [tankvorgaenge, setzeTankvorgaenge] = useState<readonly Tankvorgang[]>([])
const [bereit, setzeBereit] = useState(false)
const [ladefehler, setzeLadefehler] = useState<string | null>(null)
const [verbindung, setzeVerbindung] = useState<Verbindungszustand>("getrennt")
const [warteschlange, setzeWarteschlange] = useState<readonly WartenderAuftrag[]>([])
// Damit profilSpeichern immer gegen den neuesten Rohstand arbeitet, auch
// wenn zwischendurch ein Push hereinkam.
const rohprofilRef = useRef<Profil | null>(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 <Kontext.Provider value={wert}>{children}</Kontext.Provider>
}
@@ -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> = {}): 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<string, unknown>)["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<string, unknown>)["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<string, unknown>)["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<string, unknown>)["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<string, unknown>)["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<string, unknown>)["saetze"] as Record<
string,
Record<string, unknown>
>
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<string, unknown>)["faellig"]).toBeNull()
})
})
+241
View File
@@ -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<string, unknown>
steuer: Record<string, unknown> & { faellig: string }
reifen: {
aktiv: "Sommer" | "Winter"
anzugsmoment: number | null
wechsel: Record<string, unknown>
sommer: Reifensatz
winter: Reifensatz
}
service: Record<string, unknown>
technik: Record<string, unknown>
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<string, unknown> {
const wert = profil[name]
if (!wert || typeof wert !== "object") return {}
return structuredClone(wert) as Record<string, unknown>
}
function text(quelle: Record<string, unknown>, feld: string, ersatz = ""): string {
const wert = quelle[feld]
return typeof wert === "string" ? wert : ersatz
}
function zahl(quelle: Record<string, unknown>, 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<string, Reifensatz>
const smartdeal = (einst["smartdeal"] ?? {}) as Record<string, unknown>
const oel = (einst["oelwechsel_intervall"] ?? {}) as Record<string, unknown>
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<string, unknown>,
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<string, unknown>
const e = (p["einstellungen"] ??= {}) as Record<string, unknown>
const r = (p["reifen"] ??= {}) as Record<string, unknown>
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<string, Reifensatz>
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
}
+103
View File
@@ -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<T>(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)
}
+112
View File
@@ -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<string | null>(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 (
<div className="dm-einrichtung">
<div className="dm-einrichtung__karte">
<span className="ads-eyebrow">DataMetric360</span>
<h1 className="dm-einrichtung__titel">Willkommen</h1>
<p className="dm-einrichtung__text">
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.
</p>
<Tile>
<Feld label="Server-Adresse">
<input
className="dm-eingabe"
inputMode="url"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder="192.168.1.20:8123"
value={adresse}
onChange={(e) => setzeAdresse(e.target.value)}
/>
</Feld>
<Feld label="Zugangs-Token" last>
<input
className="dm-eingabe"
type="password"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder="einfügen"
value={token}
onChange={(e) => setzeToken(e.target.value)}
/>
</Feld>
</Tile>
{fehler && (
<p className="dm-einrichtung__fehler" role="alert">
{fehler}
</p>
)}
<div className="dm-einrichtung__knoepfe">
<ActionButton onClick={() => void verbinden()} disabled={laeuft}>
{laeuft ? "Verbinde …" : "Verbinden"}
</ActionButton>
</div>
<p className="dm-einrichtung__hinweis">
Den Token erzeugst du in Home Assistant unter Profil Sicherheit Langlebige
Zugriffstoken". Deine Daten bleiben auf deinem Server.
</p>
</div>
</div>
)
}
@@ -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 (
<div className={offline ? "dm-hinweis dm-hinweis--offline" : "dm-hinweis"} role="status">
<SymbolWarnung groesse={16} />
<span>
{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}
</span>
</div>
)
}
+36
View File
@@ -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 (
<div className="dm-leer">
<div className="dm-leer__titel">Daten nicht erreichbar</div>
<p className="dm-leer__text">{daten.ladefehler}</p>
</div>
)
}
return <div className="dm-laden">Daten werden geladen </div>
}
return <Platzhalter route={props.route} geheZu={props.geheZu} />
}
+167
View File
@@ -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;
}