Phase 7a: Uebersicht, Fahrzeugstatus und Mein Audi
Erste drei Screens auf der fertigen Datenschicht. Uebersicht mit Fahrzeugbild, Sicherungsstatus, Reichweite und Tankfuellstand, Kilometerstand samt Service-Faelligkeit und Vorschau auf letzte Fahrt und Tankung. Fahrzeugstatus zeigt die 16 Einzelpruefungen, wobei ein unbekannter Sensor als hohler Ring erscheint statt als gruen geratener Punkt - dieselbe Vorsicht, die schon das Backend walten laesst. Mein Audi buendelt Fotogalerie und Wege zu den Unterseiten. Dazu die geteilte Rechenlogik der Statistik portiert (Zeitraeume, Langzeitverbrauch mit Plausibilitaetsfenster, Gruppierung nach Jahr und Monat) und Formatierung im deutschen Format. Bild-Komponente kapselt eine Reibung zwischen App und Bibliothek: die App laeuft mit exactOptionalPropertyTypes, ein undefined an einem optionalen Prop waere sonst an jeder Aufrufstelle einzeln zu behandeln.
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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<Zeitraum, string> = {
|
||||
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<Zeitraum, { km: number; stunden: number; liter: number; kosten: number }>
|
||||
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<T> {
|
||||
/** Format YYYY-MM, damit sich absteigend sortieren lässt. */
|
||||
schluessel: string
|
||||
jahr: number
|
||||
monat: number
|
||||
eintraege: T[]
|
||||
}
|
||||
|
||||
export interface JahresGruppe<T> {
|
||||
jahr: number
|
||||
monate: MonatsGruppe<T>[]
|
||||
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<T>(
|
||||
eintraege: readonly T[],
|
||||
zeitpunktVon: (eintrag: T) => string,
|
||||
): JahresGruppe<T>[] {
|
||||
const jahre = new Map<number, Map<string, MonatsGruppe<T>>>()
|
||||
|
||||
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),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 (
|
||||
<ImagePlaceholder
|
||||
{...(url ? { src: url } : {})}
|
||||
{...(groesse ? { size: groesse } : {})}
|
||||
alt={beschreibung}
|
||||
label={name}
|
||||
hint={datei ?? undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (
|
||||
<Leerzustand
|
||||
titel="Keine Zustandsdaten"
|
||||
text="Das Fahrzeug meldet derzeit keine Tür-, Fenster- und Schlossdaten. Sobald es das tut, stehen sie hier."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const unbekannt = punkte.filter((p) => p.ok === null).length
|
||||
const offen = punkte.filter((p) => p.ok === false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusRow
|
||||
status={fahrzeug.gesichert === true ? "ok" : fahrzeug.gesichert === false ? "bad" : "warn"}
|
||||
title={
|
||||
fahrzeug.gesichert === true
|
||||
? "Alles verschlossen"
|
||||
: fahrzeug.gesichert === false
|
||||
? `${offen.length} Stelle${offen.length === 1 ? "" : "n"} offen`
|
||||
: "Zustand nicht vollständig bekannt"
|
||||
}
|
||||
{...(statusStand ? { subtitle: `Stand ${datumZeit(statusStand)}` } : {})}
|
||||
/>
|
||||
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Einzelprüfungen</span>
|
||||
<ul className="dm-pruefliste">
|
||||
{punkte.map((punkt) => (
|
||||
<li key={punkt.label} className="dm-pruefliste__zeile">
|
||||
<span
|
||||
className={
|
||||
punkt.ok === true
|
||||
? "dm-punkt dm-punkt--ok"
|
||||
: punkt.ok === false
|
||||
? "dm-punkt dm-punkt--offen"
|
||||
: "dm-punkt dm-punkt--unbekannt"
|
||||
}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="dm-pruefliste__label">{punkt.label}</span>
|
||||
<span className="dm-pruefliste__wert">
|
||||
{punkt.ok === true ? "zu" : punkt.ok === false ? "offen" : "unbekannt"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Tile>
|
||||
|
||||
{unbekannt > 0 && (
|
||||
<p className="dm-fussnote">
|
||||
{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.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Fotogalerie</span>
|
||||
<div className="dm-galerie">
|
||||
{BILDPLAETZE.map((platz) => (
|
||||
<Bild
|
||||
key={platz.datei}
|
||||
datei={platz.datei}
|
||||
beschreibung={platz.beschreibung}
|
||||
name={platz.name}
|
||||
groesse="mini"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="dm-fussnote">
|
||||
Fotos verwaltest du in den Einstellungen. Fehlende Bilder zeigen ihren erwarteten
|
||||
Dateinamen, damit klar ist, was wohin gehört.
|
||||
</p>
|
||||
</Tile>
|
||||
|
||||
<div className="dm-raster">
|
||||
<NaviKachel
|
||||
titel="Fahrzeugdaten"
|
||||
wert={fahrzeug.fin || "Technik und Ausstattung"}
|
||||
beiKlick={() => geheZu("ident")}
|
||||
/>
|
||||
<NaviKachel
|
||||
titel="Batterie"
|
||||
wert={
|
||||
fahrzeug.batteriespannung != null
|
||||
? `${de(fahrzeug.batteriespannung, 2)} V`
|
||||
: "Kein Spannungssensor"
|
||||
}
|
||||
beiKlick={() => geheZu("battverlauf")}
|
||||
/>
|
||||
<NaviKachel titel="Service" wert="Termine und Servicebuch" beiKlick={() => geheZu("service")} />
|
||||
<NaviKachel
|
||||
titel="Reifen"
|
||||
wert={`${fahrzeug.reifen.aktiv} · ${deOderStrich(aktiverSatz.km)} km`}
|
||||
beiKlick={() => geheZu("reifen")}
|
||||
/>
|
||||
<NaviKachel
|
||||
titel="Versicherung & Steuer"
|
||||
wert="Beitrag, Vertrag, Kfz-Steuer"
|
||||
beiKlick={() => geheZu("vers")}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className="dm-szene">
|
||||
<Bild
|
||||
datei={einstellungen.startbild}
|
||||
beschreibung={einstellungen.fahrzeugtitel}
|
||||
name="Fahrzeugbild"
|
||||
/>
|
||||
<div className="dm-szene__text">
|
||||
<div className="ads-title">
|
||||
{einstellungen.fahrzeugtitel}{" "}
|
||||
{einstellungen.fahrzeugzusatz && (
|
||||
<span className="ads-sport">{einstellungen.fahrzeugzusatz}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="ads-eyebrow">
|
||||
{einstellungen.kennzeichen || "Kennzeichen nicht hinterlegt"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StatusRow
|
||||
status={sicherheitsstatus}
|
||||
title={sicherheitstext}
|
||||
{...(statusStand ? { subtitle: `Stand ${datumZeit(statusStand)}` } : {})}
|
||||
onClick={() => geheZu("sicherheit")}
|
||||
/>
|
||||
|
||||
<div className="dm-raster">
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Reichweite</span>
|
||||
<Fig value={deOderStrich(fahrzeug.reichweite)} unit="km" size={40} />
|
||||
<ProgressBar percent={Math.max(0, Math.min(100, fahrzeug.tankPct))} />
|
||||
<div className="dm-tanktext">
|
||||
<span>{de(fahrzeug.tankPct)} % im Tank</span>
|
||||
<span>
|
||||
rund {de((einstellungen.tankvolumen * fahrzeug.tankPct) / 100, 1)} von{" "}
|
||||
{de(einstellungen.tankvolumen)} l
|
||||
</span>
|
||||
</div>
|
||||
</Tile>
|
||||
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Kilometerstand</span>
|
||||
<Fig
|
||||
value={fahrzeug.odoBekannt ? de(fahrzeug.odo) : "—"}
|
||||
unit="km"
|
||||
size={40}
|
||||
/>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile
|
||||
label="Nächster Ölwechsel"
|
||||
wert={fahrzeug.oelwechselFaelligTs ? datum(fahrzeug.oelwechselFaelligTs) : "—"}
|
||||
zusatz={
|
||||
fahrzeug.oelwechselFaelligKm != null
|
||||
? `in ${de(Math.abs(fahrzeug.oelwechselFaelligKm))} km`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Nächste Inspektion"
|
||||
wert={fahrzeug.inspektionFaelligTs ? datum(fahrzeug.inspektionFaelligTs) : "—"}
|
||||
zusatz={
|
||||
fahrzeug.inspektionFaelligKm != null
|
||||
? `in ${de(Math.abs(fahrzeug.inspektionFaelligKm))} km`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Tile>
|
||||
</div>
|
||||
|
||||
<div className="dm-raster">
|
||||
{letzteFahrt ? (
|
||||
<NaviKachel
|
||||
titel="Letzte Fahrt"
|
||||
wert={
|
||||
<>
|
||||
<div className="dm-navikachel__zahl">
|
||||
{deOderStrich(letzteFahrt.distance_km, 1)} km
|
||||
</div>
|
||||
<small>
|
||||
{datum(letzteFahrt.ts_start)} · {dauer(letzteFahrt.duration_s)}
|
||||
</small>
|
||||
</>
|
||||
}
|
||||
beiKlick={() => geheZu("trip", letzteFahrt.trip_id)}
|
||||
/>
|
||||
) : (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Letzte Fahrt</span>
|
||||
<Leerzustand
|
||||
titel="Noch keine Fahrt erfasst"
|
||||
text="Sobald du losfährst, erscheint hier die letzte Fahrt."
|
||||
/>
|
||||
</Tile>
|
||||
)}
|
||||
|
||||
{letzteTankung ? (
|
||||
<NaviKachel
|
||||
titel="Letzte Tankung"
|
||||
wert={
|
||||
<>
|
||||
<div className="dm-navikachel__zahl">
|
||||
{deOderStrich(letzteTankung.liters, 2)} l
|
||||
</div>
|
||||
<small>
|
||||
{datum(letzteTankung.ts)} ·{" "}
|
||||
{letzteTankung.fuel_total_eur != null
|
||||
? `${eur(letzteTankung.fuel_total_eur)} €`
|
||||
: "Betrag offen"}
|
||||
</small>
|
||||
</>
|
||||
}
|
||||
beiKlick={() => geheZu("fill", letzteTankung.tank_id)}
|
||||
/>
|
||||
) : (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Letzte Tankung</span>
|
||||
<Leerzustand
|
||||
titel="Noch keine Tankung erfasst"
|
||||
text="Tankvorgänge erkennt die App automatisch am steigenden Füllstand — oder du trägst sie selbst ein."
|
||||
/>
|
||||
</Tile>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="dm-aktionen">
|
||||
<button
|
||||
type="button"
|
||||
className="dm-textknopf"
|
||||
onClick={() => void aktualisieren()}
|
||||
disabled={aktualisiert}
|
||||
>
|
||||
{aktualisiert ? "Frage das Fahrzeug …" : "Jetzt beim Fahrzeug nachfragen"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="dm-leer">
|
||||
<div className="dm-leer__titel">{titel}</div>
|
||||
<p className="dm-leer__text">{text}</p>
|
||||
{aktion && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<ActionButton onClick={aktion.beiKlick}>{aktion.beschriftung}</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Abschnittsüberschrift zwischen Kacheln. */
|
||||
export function Abschnitt({ titel, kinder }: { titel: string; kinder?: ReactNode }) {
|
||||
return (
|
||||
<div className="dm-abschnitt">
|
||||
<span className="ads-eyebrow">{titel}</span>
|
||||
{kinder}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Kachel, die zu einer Unterseite führt. */
|
||||
export function NaviKachel({
|
||||
titel,
|
||||
wert,
|
||||
beiKlick,
|
||||
}: {
|
||||
titel: string
|
||||
wert?: ReactNode
|
||||
beiKlick: () => void
|
||||
}) {
|
||||
return (
|
||||
<Tile variant="button" chevron onClick={beiKlick}>
|
||||
<span className="ads-eyebrow">{titel}</span>
|
||||
{wert !== undefined && <div className="dm-navikachel__wert">{wert}</div>}
|
||||
</Tile>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="dm-wertzeile">
|
||||
<dt>{label}</dt>
|
||||
<dd>
|
||||
{wert}
|
||||
{zusatz && <small>{zusatz}</small>}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Werteliste({ kinder }: { kinder: ReactNode }) {
|
||||
return <dl className="dm-werteliste">{kinder}</dl>
|
||||
}
|
||||
|
||||
/** Bestätigung vor unwiderruflichen Aktionen. */
|
||||
export function bestaetigen(frage: string): boolean {
|
||||
return window.confirm(frage)
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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 <div className="dm-laden">Daten werden geladen …</div>
|
||||
}
|
||||
|
||||
return <Platzhalter route={props.route} geheZu={props.geheZu} />
|
||||
const { route, geheZu } = props
|
||||
|
||||
switch (route.name) {
|
||||
case "home":
|
||||
return <Uebersicht geheZu={geheZu} />
|
||||
case "sicherheit":
|
||||
return <Fahrzeugstatus />
|
||||
case "audi":
|
||||
return <MeinAudi geheZu={geheZu} />
|
||||
default:
|
||||
return <Platzhalter route={route} geheZu={geheZu} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
gemerkt = await zugangLesen()
|
||||
}
|
||||
|
||||
export function zugangLesenSynchron(): Zugang | null {
|
||||
return gemerkt
|
||||
}
|
||||
|
||||
export function zugangVergessen(): void {
|
||||
gemerkt = null
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user