diff --git a/companion-app/src/App.tsx b/companion-app/src/App.tsx index 082fe9a..5efa43b 100644 --- a/companion-app/src/App.tsx +++ b/companion-app/src/App.tsx @@ -8,6 +8,7 @@ import { useTabBeschriftung, useTheme } from "./theme" import { Einrichtung } from "./screens/Einrichtung" import { Hinweisleiste } from "./screens/Hinweisleiste" import { SeiteFuer } from "./screens/register" +import { zugangMerken } from "./screens/zugang" import "./stile/rahmen.css" import "./stile/screens.css" @@ -28,7 +29,11 @@ export function App() { const api = useMemo(() => new DataMetricApi(), []) useEffect(() => { - void zugangLesen().then((zugang) => setzeStart(zugang ? "bereit" : "einrichten")) + void zugangLesen().then(async (zugang) => { + // Adresse für Bild-URLs merken, bevor der erste Screen rendert. + if (zugang) await zugangMerken() + setzeStart(zugang ? "bereit" : "einrichten") + }) }, []) return ( diff --git a/companion-app/src/daten/statistik.ts b/companion-app/src/daten/statistik.ts new file mode 100644 index 0000000..579db90 --- /dev/null +++ b/companion-app/src/daten/statistik.ts @@ -0,0 +1,242 @@ +/** + * Auswertung von Fahrten und Tankvorgängen. + * + * Aus dem alten Panel portiert (`periodStart`, `fahrtenSeit`, `tankSeit`, + * `istNachtZeit`, `langzeitverbrauch`). Die Rechenregeln sind bewusst + * unverändert — sie sind über mehrere Runden entstanden und begründet. + */ + +import type { Fahrt, Tankvorgang } from "../api" +import { summe } from "../format" + +export type Zeitraum = "jahr" | "monat" | "woche" | "tag" + +export const ZEITRAEUME: Zeitraum[] = ["jahr", "monat", "woche", "tag"] + +export const ZEITRAUM_NAME: Record = { + jahr: "Jahr", + monat: "Monat", + woche: "Woche", + tag: "Tag", +} + +/** Beginn des Zeitraums. Die Woche startet montags, nicht sonntags. */ +export function zeitraumStart(art: Zeitraum, jetzt = new Date()): Date { + if (art === "jahr") return new Date(jetzt.getFullYear(), 0, 1) + if (art === "monat") return new Date(jetzt.getFullYear(), jetzt.getMonth(), 1) + const d = new Date(jetzt) + d.setHours(0, 0, 0, 0) + if (art === "woche") d.setDate(d.getDate() - ((d.getDay() + 6) % 7)) + return d +} + +export function fahrtenSeit(fahrten: readonly Fahrt[], art: Zeitraum, jetzt = new Date()): Fahrt[] { + const start = zeitraumStart(art, jetzt) + return fahrten.filter((f) => f.distance_km != null && new Date(f.ts_start) >= start) +} + +export function tankSeit( + tankvorgaenge: readonly Tankvorgang[], + art: Zeitraum, + jetzt = new Date(), +): Tankvorgang[] { + const start = zeitraumStart(art, jetzt) + return tankvorgaenge.filter((t) => t.liters != null && new Date(t.ts) >= start) +} + +/** + * Liegt der Zeitpunkt in der Nachtspanne? Die Spanne darf über Mitternacht + * gehen (22:00 bis 06:00) — dann gilt sie außerhalb statt innerhalb. + */ +export function istNachtZeit(zeitpunkt: string | Date, von: string, bis: string): boolean { + const d = zeitpunkt instanceof Date ? zeitpunkt : new Date(zeitpunkt) + const uhrzeit = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}` + return von > bis ? uhrzeit >= von || uhrzeit < bis : uhrzeit >= von && uhrzeit < bis +} + +export interface Langzeitverbrauch { + /** null, wenn kein plausibler Wert bestimmbar ist. */ + wert: number | null + km: number + liter: number + bis: Date | null + basis: "Kilometerstand an den Tankvorgängen" | "erfasste Fahrten" | "–" + /** Ein Wert kam heraus, lag aber außerhalb des Plausibilitätsfensters. */ + unplausibel: boolean +} + +/** + * Alle je getankten Liter gegen die Strecke bis zum letzten Tankstop. + * + * Bewusst nicht „Liter des Zeitraums geteilt durch km des Zeitraums": das ergab + * unmögliche Werte, sobald eine Fahrt fehlte oder ein Tankvorgang noch + * unvollständig war. Werte außerhalb von 3 bis 30 l/100 km gelten als + * unbekannt statt als Tatsache. + */ +export function langzeitverbrauch( + fahrten: readonly Fahrt[], + tankvorgaenge: readonly Tankvorgang[], +): Langzeitverbrauch { + const tanks = tankvorgaenge + .filter((t) => t.liters != null) + .slice() + .sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime()) + + if (tanks.length === 0) { + return { wert: null, km: 0, liter: 0, bis: null, basis: "–", unplausibel: false } + } + + const letzter = tanks[tanks.length - 1]! + const bis = new Date(letzter.ts) + const liter = summe(tanks, (t) => t.liters ?? 0) + + // Bevorzugt der Kilometerstand an den Tankvorgängen selbst — er zählt auch + // Strecke mit, für die keine Fahrt aufgezeichnet wurde. + const mitOdo = tanks.filter((t) => t.odometer_km != null) + let km: number + let basis: Langzeitverbrauch["basis"] + if (mitOdo.length >= 2) { + km = (mitOdo[mitOdo.length - 1]!.odometer_km ?? 0) - (mitOdo[0]!.odometer_km ?? 0) + basis = "Kilometerstand an den Tankvorgängen" + } else { + km = summe( + fahrten.filter((f) => f.distance_km != null && new Date(f.ts_start) <= bis), + (f) => f.distance_km ?? 0, + ) + basis = "erfasste Fahrten" + } + + const roh = km > 0 ? (liter / km) * 100 : null + const gueltig = roh !== null && roh >= 3 && roh <= 30 + return { + wert: gueltig ? roh : null, + km, + liter, + bis, + basis, + unplausibel: roh !== null && !gueltig, + } +} + +export interface Jahresauswertung { + jahr: number + kmJahr: number + stundenJahr: number + literJahr: number + kostenJahr: number + tagKm: number + nachtKm: number + arbeitKm: number + privatKm: number + arbeitFahrten: number + privatFahrten: number + jeZeitraum: Record + langzeit: Langzeitverbrauch +} + +export function jahresauswertung( + fahrten: readonly Fahrt[], + tankvorgaenge: readonly Tankvorgang[], + nachtVon: string, + nachtBis: string, + jetzt = new Date(), +): Jahresauswertung { + const jeZeitraum = {} as Jahresauswertung["jeZeitraum"] + for (const art of ZEITRAEUME) { + const f = fahrtenSeit(fahrten, art, jetzt) + const t = tankSeit(tankvorgaenge, art, jetzt) + jeZeitraum[art] = { + km: summe(f, (x) => x.distance_km ?? 0), + stunden: summe(f, (x) => x.duration_s ?? 0) / 3600, + liter: summe(t, (x) => x.liters ?? 0), + kosten: summe(t, (x) => x.fuel_total_eur ?? 0), + } + } + + const fJahr = fahrtenSeit(fahrten, "jahr", jetzt) + const kmJahr = jeZeitraum.jahr.km + const tagKm = summe( + fJahr.filter((f) => !istNachtZeit(f.ts_start, nachtVon, nachtBis)), + (f) => f.distance_km ?? 0, + ) + const arbeit = fJahr.filter((f) => f.art === "arbeitsweg") + const arbeitKm = summe(arbeit, (f) => f.distance_km ?? 0) + + return { + jahr: jetzt.getFullYear(), + kmJahr, + stundenJahr: jeZeitraum.jahr.stunden, + literJahr: jeZeitraum.jahr.liter, + kostenJahr: jeZeitraum.jahr.kosten, + tagKm, + nachtKm: kmJahr - tagKm, + arbeitKm, + privatKm: kmJahr - arbeitKm, + arbeitFahrten: arbeit.length, + privatFahrten: fJahr.length - arbeit.length, + jeZeitraum, + langzeit: langzeitverbrauch(fahrten, tankvorgaenge), + } +} + +/* ------------------------------------------------- Gruppierung für Listen */ + +export interface MonatsGruppe { + /** Format YYYY-MM, damit sich absteigend sortieren lässt. */ + schluessel: string + jahr: number + monat: number + eintraege: T[] +} + +export interface JahresGruppe { + jahr: number + monate: MonatsGruppe[] + eintraege: T[] +} + +/** + * Gruppiert nach Jahr und Monat, beide absteigend — das Neueste zuerst, + * so wie es die Listenansichten von Fahrten und Tankvorgängen zeigen. + */ +export function nachJahrUndMonat( + eintraege: readonly T[], + zeitpunktVon: (eintrag: T) => string, +): JahresGruppe[] { + const jahre = new Map>>() + + const sortiert = eintraege + .slice() + .sort((a, b) => new Date(zeitpunktVon(b)).getTime() - new Date(zeitpunktVon(a)).getTime()) + + for (const eintrag of sortiert) { + const d = new Date(zeitpunktVon(eintrag)) + if (Number.isNaN(d.getTime())) continue + const jahr = d.getFullYear() + const monat = d.getMonth() + const schluessel = `${jahr}-${String(monat + 1).padStart(2, "0")}` + + let monate = jahre.get(jahr) + if (!monate) { + monate = new Map() + jahre.set(jahr, monate) + } + let gruppe = monate.get(schluessel) + if (!gruppe) { + gruppe = { schluessel, jahr, monat, eintraege: [] } + monate.set(schluessel, gruppe) + } + gruppe.eintraege.push(eintrag) + } + + return [...jahre.entries()] + .sort((a, b) => b[0] - a[0]) + .map(([jahr, monate]) => { + const sortierteMonate = [...monate.values()].sort((a, b) => b.monat - a.monat) + return { + jahr, + monate: sortierteMonate, + eintraege: sortierteMonate.flatMap((m) => m.eintraege), + } + }) +} diff --git a/companion-app/src/screens/Bild.tsx b/companion-app/src/screens/Bild.tsx new file mode 100644 index 0000000..4b579ff --- /dev/null +++ b/companion-app/src/screens/Bild.tsx @@ -0,0 +1,35 @@ +/** + * Fahrzeugbild mit Platzhalter. + * + * Kapselt eine Reibung zwischen App und Bibliothek: Die App läuft mit + * `exactOptionalPropertyTypes`, weshalb `src={undefined}` an einem `src?: string` + * nicht zulässig ist. Statt an jeder Aufrufstelle bedingt zu verteilen, passiert + * es hier einmal. + */ + +import { ImagePlaceholder } from "@audi-dash/ui" + +import { bildUrl } from "./bilder" + +export function Bild({ + datei, + beschreibung, + name, + groesse, +}: { + datei: string | null | undefined + beschreibung: string + name: string + groesse?: "default" | "mini" | "round" +}) { + const url = bildUrl(datei) + return ( + + ) +} diff --git a/companion-app/src/screens/Einrichtung.tsx b/companion-app/src/screens/Einrichtung.tsx index 76cf81c..2c40d07 100644 --- a/companion-app/src/screens/Einrichtung.tsx +++ b/companion-app/src/screens/Einrichtung.tsx @@ -12,6 +12,7 @@ import { useState } from "react" import { ActionButton, Feld, Tile } from "@audi-dash/ui" import { ApiFehler, HassRest, basisUrlNormalisieren, zugangSpeichern } from "../api" +import { zugangMerken } from "./zugang" export function Einrichtung({ fertig }: { fertig: () => void }) { const [adresse, setzeAdresse] = useState("") @@ -37,6 +38,7 @@ export function Einrichtung({ fertig }: { fertig: () => void }) { // die nachweislich nicht funktionieren. await new HassRest(zugang).verbindungPruefen() await zugangSpeichern(zugang) + await zugangMerken() fertig() } catch (ursache) { if (ursache instanceof ApiFehler && ursache.istAnmeldeproblem) { diff --git a/companion-app/src/screens/Fahrzeugstatus.tsx b/companion-app/src/screens/Fahrzeugstatus.tsx new file mode 100644 index 0000000..d253895 --- /dev/null +++ b/companion-app/src/screens/Fahrzeugstatus.tsx @@ -0,0 +1,80 @@ +/** + * Fahrzeugstatus im Detail. Vorlage: `vSicherheit()` im alten Panel. + * + * Zeigt die 16 Einzelprüfungen aus `_sicherheitscheck`. Entscheidend: + * `ok === null` heißt „Sensor meldet nichts" und wird als unbekannt + * dargestellt — nie als grün geraten. Deshalb ist auch die Gesamtaussage + * unbekannt, sobald eine einzige Prüfung unbekannt ist. + */ + +import { StatusRow, Tile } from "@audi-dash/ui" + +import { useDaten } from "../daten/DatenKontext" +import { datumZeit } from "../format" +import { Leerzustand } from "./bausteine" + +export function Fahrzeugstatus() { + const { fahrzeug, statusStand } = useDaten() + if (!fahrzeug) return null + + const punkte = fahrzeug.sicherheitscheck + if (punkte.length === 0) { + return ( + + ) + } + + const unbekannt = punkte.filter((p) => p.ok === null).length + const offen = punkte.filter((p) => p.ok === false) + + return ( + <> + + + + Einzelprüfungen +
    + {punkte.map((punkt) => ( +
  • +
  • + ))} +
+
+ + {unbekannt > 0 && ( +

+ {unbekannt === 1 ? "Eine Prüfung ist" : `${unbekannt} Prüfungen sind`} unbekannt, weil das + Fahrzeug dazu nichts meldet. Solange das so ist, gilt die Gesamtaussage bewusst als + unbekannt statt als sicher. +

+ )} + + ) +} diff --git a/companion-app/src/screens/MeinAudi.tsx b/companion-app/src/screens/MeinAudi.tsx new file mode 100644 index 0000000..63c4029 --- /dev/null +++ b/companion-app/src/screens/MeinAudi.tsx @@ -0,0 +1,72 @@ +/** + * „Mein Audi" — Sammelseite mit Fotogalerie und Wegen zu den Unterseiten. + * Vorlage: `vAudi()` im alten Panel. + */ + +import { Tile } from "@audi-dash/ui" + +import { useDaten } from "../daten/DatenKontext" +import { de, deOderStrich } from "../format" +import type { SeitenName } from "../navigation" +import { NaviKachel } from "./bausteine" +import { Bild } from "./Bild" +import { BILDPLAETZE } from "./bilder" + +export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) { + const { fahrzeug, einstellungen } = useDaten() + if (!fahrzeug || !einstellungen) return null + + const aktiverSatz = + fahrzeug.reifen.aktiv === "Winter" ? fahrzeug.reifen.winter : fahrzeug.reifen.sommer + + return ( + <> + + Fotogalerie +
+ {BILDPLAETZE.map((platz) => ( + + ))} +
+

+ Fotos verwaltest du in den Einstellungen. Fehlende Bilder zeigen ihren erwarteten + Dateinamen, damit klar ist, was wohin gehört. +

+
+ +
+ geheZu("ident")} + /> + geheZu("battverlauf")} + /> + geheZu("service")} /> + geheZu("reifen")} + /> + geheZu("vers")} + /> +
+ + ) +} diff --git a/companion-app/src/screens/Uebersicht.tsx b/companion-app/src/screens/Uebersicht.tsx new file mode 100644 index 0000000..d9734be --- /dev/null +++ b/companion-app/src/screens/Uebersicht.tsx @@ -0,0 +1,194 @@ +/** + * Übersicht — die Startseite. Vorlage: `vHome()` im alten Panel. + * + * Fahrzeugbild, Status „sicher abgestellt", Reichweite und Tankfüllstand, + * Kilometerstand, nächster Service, Vorschau auf letzte Fahrt und letzte + * Tankung. Ziehen zum Aktualisieren stößt eine Neuabfrage beim Fahrzeug an. + */ + +import { useState } from "react" + +import { Fig, ProgressBar, StatusRow, Tile } from "@audi-dash/ui" + +import { useDaten } from "../daten/DatenKontext" +import { datum, datumZeit, dauer, de, deOderStrich, eur } from "../format" +import type { SeitenName } from "../navigation" +import { Leerzustand, NaviKachel, Wertzeile, Werteliste } from "./bausteine" +import { Bild } from "./Bild" + +export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) { + const { fahrzeug, einstellungen, fahrten, tankvorgaenge, jetztAktualisieren, statusStand } = + useDaten() + const [aktualisiert, setzeAktualisiert] = useState(false) + + if (!fahrzeug || !einstellungen) return null + + const letzteFahrt = fahrten + .slice() + .sort((a, b) => new Date(b.ts_start).getTime() - new Date(a.ts_start).getTime())[0] + const letzteTankung = tankvorgaenge + .slice() + .sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime())[0] + + const sicherheitsstatus = + fahrzeug.gesichert === true ? "ok" : fahrzeug.gesichert === false ? "bad" : "warn" + const sicherheitstext = + fahrzeug.gesichert === true + ? "Sicher abgestellt" + : fahrzeug.gesichert === false + ? "Nicht vollständig verschlossen" + : "Zustand unbekannt" + + const aktualisieren = async () => { + setzeAktualisiert(true) + try { + await jetztAktualisieren() + } finally { + setzeAktualisiert(false) + } + } + + return ( + <> +
+ +
+
+ {einstellungen.fahrzeugtitel}{" "} + {einstellungen.fahrzeugzusatz && ( + {einstellungen.fahrzeugzusatz} + )} +
+ + {einstellungen.kennzeichen || "Kennzeichen nicht hinterlegt"} + +
+
+ + geheZu("sicherheit")} + /> + +
+ + Reichweite + + +
+ {de(fahrzeug.tankPct)} % im Tank + + rund {de((einstellungen.tankvolumen * fahrzeug.tankPct) / 100, 1)} von{" "} + {de(einstellungen.tankvolumen)} l + +
+
+ + + Kilometerstand + + + + + + } + /> + +
+ +
+ {letzteFahrt ? ( + +
+ {deOderStrich(letzteFahrt.distance_km, 1)} km +
+ + {datum(letzteFahrt.ts_start)} · {dauer(letzteFahrt.duration_s)} + + + } + beiKlick={() => geheZu("trip", letzteFahrt.trip_id)} + /> + ) : ( + + Letzte Fahrt + + + )} + + {letzteTankung ? ( + +
+ {deOderStrich(letzteTankung.liters, 2)} l +
+ + {datum(letzteTankung.ts)} ·{" "} + {letzteTankung.fuel_total_eur != null + ? `${eur(letzteTankung.fuel_total_eur)} €` + : "Betrag offen"} + + + } + beiKlick={() => geheZu("fill", letzteTankung.tank_id)} + /> + ) : ( + + Letzte Tankung + + + )} +
+ +
+ +
+ + ) +} diff --git a/companion-app/src/screens/bausteine.tsx b/companion-app/src/screens/bausteine.tsx new file mode 100644 index 0000000..24f034b --- /dev/null +++ b/companion-app/src/screens/bausteine.tsx @@ -0,0 +1,96 @@ +/** + * Kleine, über mehrere Seiten geteilte Bausteine. Alles, was mehr als einmal + * gebraucht wird, aber zu speziell für das Design-System ist. + */ + +import type { ReactNode } from "react" + +import { ActionButton, Tile } from "@audi-dash/ui" + +/** + * Leerzustand. Aus dem Design-Auftrag: freundlich formuliert, mit einem + * Hinweis, was als Nächstes passiert — nie nur „keine Daten". + * Wird real gebraucht: Fahrten und Tankvorgänge sind zu Beginn leer. + */ +export function Leerzustand({ + titel, + text, + aktion, +}: { + titel: string + text: string + aktion?: { beschriftung: string; beiKlick: () => void } +}) { + return ( +
+
{titel}
+

{text}

+ {aktion && ( +
+ {aktion.beschriftung} +
+ )} +
+ ) +} + +/** Abschnittsüberschrift zwischen Kacheln. */ +export function Abschnitt({ titel, kinder }: { titel: string; kinder?: ReactNode }) { + return ( +
+ {titel} + {kinder} +
+ ) +} + +/** Kachel, die zu einer Unterseite führt. */ +export function NaviKachel({ + titel, + wert, + beiKlick, +}: { + titel: string + wert?: ReactNode + beiKlick: () => void +}) { + return ( + + {titel} + {wert !== undefined &&
{wert}
} +
+ ) +} + +/** + * Ein Wert mit Beschriftung. „—" statt eines erfundenen Werts, wenn nichts + * bekannt ist — die Regel zieht sich durch die ganze App. + */ +export function Wertzeile({ + label, + wert, + zusatz, +}: { + label: ReactNode + wert: ReactNode + zusatz?: ReactNode +}) { + return ( +
+
{label}
+
+ {wert} + {zusatz && {zusatz}} +
+
+ ) +} + +export function Werteliste({ kinder }: { kinder: ReactNode }) { + return
{kinder}
+} + +/** Bestätigung vor unwiderruflichen Aktionen. */ +export function bestaetigen(frage: string): boolean { + return window.confirm(frage) +} diff --git a/companion-app/src/screens/bilder.ts b/companion-app/src/screens/bilder.ts new file mode 100644 index 0000000..02316bf --- /dev/null +++ b/companion-app/src/screens/bilder.ts @@ -0,0 +1,49 @@ +/** + * Fahrzeugfotos. Die Dateinamen sind fest: `bilderverwaltung.py` prüft jeden + * Upload gegen eine Positivliste und lehnt alles andere ab — hier steht + * dieselbe Liste, damit die Oberfläche gar nicht erst etwas anbieten kann, + * das der Server ablehnen würde. + */ + +import { zugangLesenSynchron } from "./zugang" + +export interface Bildplatz { + name: string + beschreibung: string + datei: string +} + +/** Die sieben Plätze der Fotogalerie unter „Mein Audi". */ +export const BILDPLAETZE: Bildplatz[] = [ + { name: "Seitenansicht", beschreibung: "Auto von der Seite", datei: "seitenansicht.webp" }, + { name: "Front schräg", beschreibung: "Auto von vorn schräg", datei: "front-schraeg.webp" }, + { name: "Frontansicht", beschreibung: "Auto von vorn", datei: "frontansicht.webp" }, + { name: "Heckansicht", beschreibung: "Auto von hinten", datei: "heckansicht.webp" }, + { name: "Cockpit", beschreibung: "Innenraum Cockpit", datei: "cockpit.webp" }, + { name: "Scheinwerfer", beschreibung: "Scheinwerfer", datei: "scheinwerfer.webp" }, + { name: "Sitze", beschreibung: "Innenraum Sitze", datei: "sitze.webp" }, +] + +/** Zusätzlich erlaubt, aber außerhalb der Galerie geführt. */ +export const WEITERE_BILDER = [ + "seitenansicht-winter.webp", + "rad-sommer.webp", + "rad-winter.webp", +] as const + +export const ERLAUBTE_DATEINAMEN: readonly string[] = [ + ...BILDPLAETZE.map((b) => b.datei), + ...WEITERE_BILDER, +] + +/** + * Adresse eines Fahrzeugfotos auf dem Server. Die Bilder liegen unter + * `/local/bilder/` — anders als beim alten Panel läuft die App nicht + * zwangsläufig auf derselben Adresse, deshalb die vollständige URL. + */ +export function bildUrl(dateiname: string | null | undefined): string | undefined { + if (!dateiname) return undefined + const basis = zugangLesenSynchron()?.basisUrl + if (!basis) return undefined + return `${basis}/local/bilder/${dateiname}` +} diff --git a/companion-app/src/screens/register.tsx b/companion-app/src/screens/register.tsx index b64e02e..4f71172 100644 --- a/companion-app/src/screens/register.tsx +++ b/companion-app/src/screens/register.tsx @@ -5,7 +5,10 @@ import { useDaten } from "../daten/DatenKontext" import type { Route, SeitenName } from "../navigation" +import { Fahrzeugstatus } from "./Fahrzeugstatus" +import { MeinAudi } from "./MeinAudi" import { Platzhalter } from "./Platzhalter" +import { Uebersicht } from "./Uebersicht" export interface SeitenProps { route: Route @@ -18,8 +21,8 @@ export interface SeitenProps { 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". + // 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 ( @@ -32,5 +35,16 @@ export function SeiteFuer(props: SeitenProps) { return
Daten werden geladen …
} - return + const { route, geheZu } = props + + switch (route.name) { + case "home": + return + case "sicherheit": + return + case "audi": + return + default: + return + } } diff --git a/companion-app/src/screens/zugang.ts b/companion-app/src/screens/zugang.ts new file mode 100644 index 0000000..011c96a --- /dev/null +++ b/companion-app/src/screens/zugang.ts @@ -0,0 +1,23 @@ +/** + * Synchroner Zugriff auf die Basis-Adresse. + * + * Die Datenschicht liest Zugangsdaten bewusst asynchron (die native Ablage + * unter iOS/Android ist es auch). Für Bild-URLs im Rendern wird die Adresse + * aber synchron gebraucht — deshalb wird sie einmal beim Start gemerkt. + */ + +import { zugangLesen, type Zugang } from "../api" + +let gemerkt: Zugang | null = null + +export async function zugangMerken(): Promise { + gemerkt = await zugangLesen() +} + +export function zugangLesenSynchron(): Zugang | null { + return gemerkt +} + +export function zugangVergessen(): void { + gemerkt = null +} diff --git a/companion-app/src/stile/screens.css b/companion-app/src/stile/screens.css index 40db87f..03d5cc7 100644 --- a/companion-app/src/stile/screens.css +++ b/companion-app/src/stile/screens.css @@ -165,3 +165,190 @@ font-size: 13.5px; font-weight: 300; } + +/* ------------------------------------------------------------ Übersicht */ + +.dm-szene { + position: relative; + border-radius: var(--r-tile); + overflow: hidden; + background: var(--tile); +} + +.dm-szene .ads-bild, +.dm-szene img { + width: 100%; + display: block; +} + +.dm-szene__text { + padding: var(--sp-4) var(--sp-4) var(--sp-5); +} + +.dm-tanktext { + display: flex; + justify-content: space-between; + gap: var(--sp-3); + margin-top: var(--sp-2); + font-size: 12.5px; + font-weight: 300; + color: var(--fg2); +} + +.dm-navikachel__wert { + margin-top: var(--sp-1); + font-size: 15px; + font-weight: 300; + color: var(--fg); +} + +.dm-navikachel__zahl { + font-size: 20px; + font-weight: 300; +} + +.dm-navikachel__wert small, +.dm-wertzeile small { + display: block; + margin-top: 2px; + font-size: 11.5px; + color: var(--fg3); +} + +.dm-aktionen { + display: flex; + justify-content: center; + padding: var(--sp-2) 0 var(--sp-4); +} + +.dm-textknopf { + border: 0; + background: transparent; + color: var(--fg2); + font-size: 13px; + font-weight: 300; + padding: 10px 14px; + border-radius: var(--r-pill); + cursor: pointer; +} + +.dm-textknopf:hover:not(:disabled) { + background: var(--shade); + color: var(--fg); +} + +.dm-textknopf:disabled { + opacity: 0.5; + cursor: default; +} + +/* --------------------------------------------------------- Werteliste */ + +.dm-werteliste { + margin: var(--sp-3) 0 0; +} + +.dm-wertzeile { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--sp-4); + padding: 10px 0; + border-bottom: 1px solid var(--line); +} + +.dm-wertzeile:last-child { + border-bottom: 0; +} + +.dm-wertzeile dt { + font-size: 13px; + font-weight: 300; + color: var(--fg2); +} + +.dm-wertzeile dd { + margin: 0; + font-size: 14px; + font-weight: 300; + text-align: right; +} + +.dm-abschnitt { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--sp-3); + padding: var(--sp-3) 2px 0; +} + +.dm-fussnote { + margin: var(--sp-3) 0 0; + font-size: 11.5px; + font-weight: 300; + line-height: 1.5; + color: var(--fg3); + text-wrap: pretty; +} + +/* ------------------------------------------------------ Fahrzeugstatus */ + +.dm-pruefliste { + list-style: none; + margin: var(--sp-3) 0 0; + padding: 0; +} + +.dm-pruefliste__zeile { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: 10px 0; + border-bottom: 1px solid var(--line); +} + +.dm-pruefliste__zeile:last-child { + border-bottom: 0; +} + +.dm-pruefliste__label { + flex: 1; + font-size: 14px; + font-weight: 300; +} + +.dm-pruefliste__wert { + font-size: 12.5px; + font-weight: 300; + color: var(--fg2); +} + +.dm-punkt { + flex: none; + width: 9px; + height: 9px; + border-radius: 50%; +} + +.dm-punkt--ok { + background: var(--ok); +} + +.dm-punkt--offen { + background: var(--bad); +} + +/* Unbekannt bewusst als hohler Ring: sichtbar anders als grün und als rot, + damit "keine Meldung" nie wie eine Aussage aussieht. */ +.dm-punkt--unbekannt { + border: 1.5px solid var(--fg3); +} + +/* ------------------------------------------------------------ Galerie */ + +.dm-galerie { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); + gap: var(--sp-2); + margin-top: var(--sp-3); +}