Rollende Zeitraeume, zwei Gesten, CI-Symbole und das Screening faellt nach (2026.9.2.8-.12)
Gemeldet vom Eigentuemer an der realen Instanz, in einem Zug abgearbeitet. STATISTIK. "Monat kleiner als Woche" war kein Rechenfehler, sondern die Definition: am 02.09. begann die Kalenderwoche am 31.08., der Monat erst am 01.09. Umgestellt auf rollende Fenster (1/7/30/365 Tage), damit 1 ⊂ 7 ⊂ 30 ⊂ 365 an jedem Tag gilt. "Jahr" meint damit nicht mehr das Kalenderjahr - so entschieden. Zwei neue Tests halten die Verschachtelung fest. Aus dem Langzeitverbrauch sind Herleitung und Erklaerabsatz aus der ANSICHT genommen, nicht aus der Rechnung; das Datum des letzten Tankstops ebenfalls. UEBERSICHT. Unter "Letzter Tankvorgang" steht das Datum statt der Tankstelle. BILDER. Die Draufsicht war der einzige sichtbare Bildplatz ohne Zugang - bilder.py kannte den Dateinamen, BILDER/BILDPLAETZE nicht. Jetzt ein Platz wie jeder andere. Die Vorschau in den Einstellungen schneidet Fotos nicht mehr an, und der Bildbereich der Fahrzeugseite steht eingerueckt statt randlos. ZAHNRAD. Es lag mit seiner 44px-Trefferflaeche 21px im Kachelinhalt und fing die obere Haelfte des ersten Knopfes ab (Versicherung, "Inland"). Die Kopfzeile bekommt jetzt die Hoehe, die es braucht. SCREENING. Lief ausschliesslich bei einer Aenderung des Kilometerstands, also nur waehrend der Fahrt - im Stand blieben Ortsnamen und Verbrauch liegen. Jetzt zusaetzlich einmal nach dem Start, und danach faellt es selbst nach, solange ein Lauf sein Ortsbudget aufbraucht UND dabei etwas aufloest. Kein Dauertakt: die Kette endet, sobald ein Lauf mit uebrigem Budget durchkommt. GESTEN. Zum Loeschen nach links wischen, jetzt auch im Radarchiv (das Panel hatte die Mechanik, das Archiv war nie angeschlossen; die App hatte sie gar nicht). Vom linken Rand nach rechts wischen = zurueck, jetzt auch in der App, mit denselben Zahlen wie im Panel und in beiden mit sichtbarer Rueckmeldung. CI-SYMBOLE. Das Kachel-Zahnrad ist settings-s - im Panel stand der Pfad sechsmal wortgleich im Markup, jetzt einmal als CI.settingsS. Das handgezeichnete Zahnrad der Seitenleiste ist ersetzt und geloescht. Die Zapfsaeule des Tanken-Reiters ist fuel-station-s, Faktor 0,84 um die Rastermitte - live per getBBox() gemessen, damit sie auf dieselbe senkrechte Spanne kommt wie Statistik. Zurueckgenommen: die Titel der Unterseiten stehen wieder mittig. Zentriert ist Apples HIG fuer Navigationsleisten; die linksbuendigen Titel sind die der Hauptbereiche und folgen der anderen Regel. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+127
-6
@@ -8,7 +8,12 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import type { PointerEvent as ReactPointerEvent, ReactNode, RefObject } from "react"
|
||||
import type {
|
||||
MutableRefObject,
|
||||
PointerEvent as ReactPointerEvent,
|
||||
ReactNode,
|
||||
RefObject,
|
||||
} from "react"
|
||||
|
||||
import { TabBar } from "@audi-dash/ui"
|
||||
|
||||
@@ -24,7 +29,7 @@ import {
|
||||
SymbolStatistik,
|
||||
SymbolTanken,
|
||||
SymbolUebersicht,
|
||||
SymbolZahnrad,
|
||||
SymbolZahnradVoll,
|
||||
SymbolZurueck,
|
||||
} from "./symbole"
|
||||
|
||||
@@ -45,9 +50,93 @@ export interface ShellProps {
|
||||
/** Ziehen zum Aktualisieren, 1:1 nach der ptr-Logik des Panels. */
|
||||
const PTR_SCHWELLE = 56
|
||||
|
||||
/*
|
||||
* Vom linken Rand nach rechts wischen = zurück.
|
||||
*
|
||||
* Warum von Hand: die App ist EINE WebView mit einer flachen Route (siehe
|
||||
* navigation.ts), kein Verlaufsstapel. iOS' eigene Zurück-Geste hängt am
|
||||
* Navigationsstapel eines UINavigationControllers - den gibt es hier nicht,
|
||||
* also gibt es die Geste auch nicht geschenkt. Bis zum 02.09.2026 war der
|
||||
* Pfeil oben links der einzige Weg zurück (gemeldet vom Eigentümer).
|
||||
*
|
||||
* RAND: die Geste muss am linken Rand beginnen. Das ist keine Nachahmung um
|
||||
* ihrer selbst willen, sondern die einzige Art, sie von allem anderen
|
||||
* Waagerechten sauber zu trennen - dem Wischen an einer Listenzeile
|
||||
* (WischLoeschen), dem seitlichen Schieben in der Fotogalerie.
|
||||
*
|
||||
* SCHWELLE: erst ab dieser Strecke wird zurückgegangen. Darunter federt die
|
||||
* Seite zurück - eine angefangene Geste ist damit widerrufbar, indem man den
|
||||
* Finger zurückführt, genau wie bei iOS.
|
||||
*/
|
||||
/* Dieselben Zahlen wie randwischenVerdrahten() im Panel (26px Rand, 62px
|
||||
Auslösung) - die Geste soll sich in beiden Oberflächen gleich anfühlen. */
|
||||
const ZURUECK_RAND = 26
|
||||
const ZURUECK_SCHWELLE = 62
|
||||
const ZURUECK_WEG = 140
|
||||
|
||||
function useWischZurueck(
|
||||
inhalt: RefObject<HTMLElement | null>,
|
||||
zurueck: (() => void) | undefined,
|
||||
belegt: MutableRefObject<boolean>,
|
||||
) {
|
||||
const [zug, setzeZug] = useState<number | null>(null)
|
||||
const start = useRef<{ x: number; y: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = inhalt.current
|
||||
// Der Aussteiger sitzt IM Effekt, nicht davor - sonst hinge die
|
||||
// Hook-Reihenfolge davon ab, ob es ein Zurück-Ziel gibt.
|
||||
if (!el || !zurueck) return undefined
|
||||
|
||||
const beiStart = (e: TouchEvent) => {
|
||||
if (e.touches.length !== 1) return
|
||||
const t = e.touches[0]!
|
||||
if (t.clientX > ZURUECK_RAND) return
|
||||
start.current = { x: t.clientX, y: t.clientY }
|
||||
}
|
||||
const beiBewegung = (e: TouchEvent) => {
|
||||
const s = start.current
|
||||
if (!s || e.touches.length !== 1) return
|
||||
const t = e.touches[0]!
|
||||
const dx = t.clientX - s.x
|
||||
const dy = t.clientY - s.y
|
||||
if (dx <= 0 || Math.abs(dx) < Math.abs(dy) * 1.5) {
|
||||
// Senkrecht oder rückwärts gemeint: die Geste gehört uns nicht.
|
||||
if (Math.abs(dy) > 10) start.current = null
|
||||
return
|
||||
}
|
||||
// Solange wir ziehen, hält sich das Ziehen-zum-Aktualisieren heraus.
|
||||
belegt.current = true
|
||||
setzeZug(Math.min(ZURUECK_WEG, dx))
|
||||
e.preventDefault()
|
||||
}
|
||||
const beiEnde = () => {
|
||||
const gezogen = zug ?? 0
|
||||
start.current = null
|
||||
belegt.current = false
|
||||
setzeZug(null)
|
||||
if (gezogen >= ZURUECK_SCHWELLE) zurueck()
|
||||
}
|
||||
|
||||
el.addEventListener("touchstart", beiStart, { passive: true })
|
||||
el.addEventListener("touchmove", beiBewegung, { passive: false })
|
||||
el.addEventListener("touchend", beiEnde)
|
||||
el.addEventListener("touchcancel", beiEnde)
|
||||
return () => {
|
||||
el.removeEventListener("touchstart", beiStart)
|
||||
el.removeEventListener("touchmove", beiBewegung)
|
||||
el.removeEventListener("touchend", beiEnde)
|
||||
el.removeEventListener("touchcancel", beiEnde)
|
||||
}
|
||||
})
|
||||
|
||||
return zug
|
||||
}
|
||||
|
||||
function useZiehenAktualisieren(
|
||||
inhalt: RefObject<HTMLElement | null>,
|
||||
beiAktualisieren: (() => Promise<void>) | undefined,
|
||||
belegt: MutableRefObject<boolean>,
|
||||
) {
|
||||
const [zug, setzeZug] = useState(0)
|
||||
const [laedt, setzeLaedt] = useState(false)
|
||||
@@ -68,7 +157,7 @@ function useZiehenAktualisieren(
|
||||
}
|
||||
|
||||
const starten = (y: number) => {
|
||||
if (laedt) return
|
||||
if (laedt || belegt.current) return
|
||||
if ((inhalt.current?.scrollTop ?? 0) > 0) return
|
||||
start.current = y
|
||||
setzeZug(0)
|
||||
@@ -77,6 +166,11 @@ function useZiehenAktualisieren(
|
||||
preventDefault() rufen. */
|
||||
const bewegen = (y: number): boolean => {
|
||||
if (start.current === null) return false
|
||||
if (belegt.current) {
|
||||
start.current = null
|
||||
setzeZug(0)
|
||||
return false
|
||||
}
|
||||
const dy = y - start.current
|
||||
if (dy > 0 && (inhalt.current?.scrollTop ?? 0) <= 0) {
|
||||
// Widerstand wie bei iOS: halbe Strecke, gedeckelt bei 90px.
|
||||
@@ -168,7 +262,20 @@ export function Shell({
|
||||
|
||||
const unterseite = !istHauptbereich(route.name)
|
||||
const inhaltRef = useRef<HTMLElement | null>(null)
|
||||
const ptr = useZiehenAktualisieren(inhaltRef, breit ? undefined : beiAktualisieren)
|
||||
// Eine Geste zur Zeit: läuft der Wisch nach rechts, hält sich das Ziehen
|
||||
// zum Aktualisieren heraus. Ohne das zeigte eine schräge Geste am oberen
|
||||
// Seitenrand beides gleichzeitig.
|
||||
const gesteBelegt = useRef(false)
|
||||
const zurueckZug = useWischZurueck(
|
||||
inhaltRef,
|
||||
zurueckZiel && !breit ? () => geheZu(zurueckZiel) : undefined,
|
||||
gesteBelegt,
|
||||
)
|
||||
const ptr = useZiehenAktualisieren(
|
||||
inhaltRef,
|
||||
breit ? undefined : beiAktualisieren,
|
||||
gesteBelegt,
|
||||
)
|
||||
|
||||
// Der Text tickt mit, wie standAlterTicken() im Panel (Sekundentakt).
|
||||
const [, neuZeichnen] = useState(0)
|
||||
@@ -273,7 +380,7 @@ export function Shell({
|
||||
}
|
||||
onClick={() => geheZu("einst")}
|
||||
>
|
||||
<SymbolZahnrad />
|
||||
<SymbolZahnradVoll />
|
||||
<span>Einstellungen</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -314,7 +421,21 @@ export function Shell({
|
||||
: "Ziehen zum Aktualisieren"}
|
||||
</span>
|
||||
</div>
|
||||
<main className="dm-scroll dm-inhalt" ref={inhaltRef} {...ptr.handler}>
|
||||
<main
|
||||
className="dm-scroll dm-inhalt"
|
||||
ref={inhaltRef}
|
||||
{...ptr.handler}
|
||||
/* Die Seite folgt dem Finger, damit die Geste sichtbar widerrufbar
|
||||
bleibt: zurückführen unter die Schwelle, und sie federt zurück.
|
||||
Ohne Zug steht hier kein transform - eine stehende Transformation
|
||||
erzeugt einen eigenen Malkontext und hätte position:fixed im Inhalt
|
||||
an sich gebunden. */
|
||||
style={
|
||||
zurueckZug === null
|
||||
? undefined
|
||||
: { transform: `translateX(${zurueckZug}px)`, transition: "none" }
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
<TabBar
|
||||
|
||||
@@ -46,25 +46,46 @@ function alsTag(d: Date): string {
|
||||
}
|
||||
|
||||
describe("zeitraumStart", () => {
|
||||
it("beginnt das Jahr am 1. Januar", () => {
|
||||
expect(alsTag(zeitraumStart("jahr", JETZT))).toBe("2026-01-01")
|
||||
it("rechnet das Jahr 365 Tage zurück", () => {
|
||||
expect(alsTag(zeitraumStart("jahr", JETZT))).toBe("2025-08-11")
|
||||
})
|
||||
|
||||
it("beginnt den Monat am Ersten", () => {
|
||||
expect(alsTag(zeitraumStart("monat", JETZT))).toBe("2026-08-01")
|
||||
it("rechnet den Monat 30 Tage zurück", () => {
|
||||
expect(alsTag(zeitraumStart("monat", JETZT))).toBe("2026-07-12")
|
||||
})
|
||||
|
||||
it("beginnt die Woche am Montag, nicht am Sonntag", () => {
|
||||
// Der 11.08.2026 ist ein Dienstag, die Woche beginnt am 10.08.
|
||||
const start = zeitraumStart("woche", JETZT)
|
||||
expect(start.getDay()).toBe(1)
|
||||
expect(start.getDate()).toBe(10)
|
||||
it("rechnet die Woche 7 Tage zurück", () => {
|
||||
expect(alsTag(zeitraumStart("woche", JETZT))).toBe("2026-08-04")
|
||||
})
|
||||
|
||||
it("beginnt den Tag um Mitternacht", () => {
|
||||
it("rechnet den Tag 24 Stunden zurück", () => {
|
||||
const start = zeitraumStart("tag", JETZT)
|
||||
expect(start.getHours()).toBe(0)
|
||||
expect(start.getDate()).toBe(11)
|
||||
expect(alsTag(start)).toBe("2026-08-10")
|
||||
expect(start.getHours()).toBe(12)
|
||||
})
|
||||
|
||||
/* Der Grund für die Umstellung, als Test: am 02.09.2026 begann die
|
||||
Kalenderwoche am Montag, dem 31.08., der Kalendermonat aber erst am
|
||||
01.09. Eine Fahrt vom 31.08. zählte damit zur Woche und nicht zum
|
||||
Monat - "Monat kleiner als Woche", vom Eigentümer an der realen Instanz
|
||||
gemeldet. Rollende Fenster sind ineinander enthalten, immer. */
|
||||
it("hält Tag, Woche, Monat und Jahr ineinander verschachtelt", () => {
|
||||
for (const tag of ["2026-09-02T09:00:00", "2026-01-01T09:00:00", "2026-03-01T00:30:00"]) {
|
||||
const jetzt = new Date(tag)
|
||||
const [j, m, w, t] = (["jahr", "monat", "woche", "tag"] as const).map((art) =>
|
||||
zeitraumStart(art, jetzt).getTime(),
|
||||
)
|
||||
expect(j!).toBeLessThanOrEqual(m!)
|
||||
expect(m!).toBeLessThanOrEqual(w!)
|
||||
expect(w!).toBeLessThanOrEqual(t!)
|
||||
}
|
||||
})
|
||||
|
||||
it("zählt eine Fahrt vom Monatsletzten in Woche UND Monat", () => {
|
||||
const jetzt = new Date("2026-09-02T09:00:00")
|
||||
const fahrten = [fahrt({ trip_id: "a", ts_start: "2026-08-31T08:00:00" })]
|
||||
expect(fahrtenSeit(fahrten, "woche", jetzt)).toHaveLength(1)
|
||||
expect(fahrtenSeit(fahrten, "monat", jetzt)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -91,7 +112,9 @@ describe("fahrtenSeit und tankSeit", () => {
|
||||
fahrt({ trip_id: "heute", ts_start: "2026-08-11T09:00:00" }),
|
||||
fahrt({ trip_id: "diesewoche", ts_start: "2026-08-10T09:00:00" }),
|
||||
fahrt({ trip_id: "diesenmonat", ts_start: "2026-08-02T09:00:00" }),
|
||||
fahrt({ trip_id: "letztesjahr", ts_start: "2025-12-30T09:00:00" }),
|
||||
// Im Kalenderjahr 2026 nicht enthalten, in den letzten 365 Tagen schon -
|
||||
// seit der Umstellung auf rollende Fenster zählt sie deshalb mit.
|
||||
fahrt({ trip_id: "dezember", ts_start: "2025-12-30T09:00:00" }),
|
||||
fahrt({ trip_id: "ohnestrecke", ts_start: "2026-08-11T10:00:00", distance_km: null }),
|
||||
]
|
||||
|
||||
@@ -101,7 +124,7 @@ describe("fahrtenSeit und tankSeit", () => {
|
||||
"heute",
|
||||
"diesewoche",
|
||||
])
|
||||
expect(fahrtenSeit(fahrten, "jahr", JETZT)).toHaveLength(3)
|
||||
expect(fahrtenSeit(fahrten, "jahr", JETZT)).toHaveLength(4)
|
||||
})
|
||||
|
||||
it("lässt Fahrten ohne Strecke außen vor — sie würden jede Summe verfälschen", () => {
|
||||
|
||||
@@ -31,16 +31,32 @@ export const ZEITRAUM_NAME: Record<Zeitraum, string> = {
|
||||
absolut: "Absolut",
|
||||
}
|
||||
|
||||
/** Beginn des Zeitraums. Die Woche startet montags, nicht sonntags. */
|
||||
/**
|
||||
* Länge der rollenden Zeiträume in Tagen.
|
||||
*
|
||||
* Bis zum 02.09.2026 waren die Spannen kalendarisch: Jahr ab 1. Januar, Monat
|
||||
* ab dem Ersten, Woche ab Montag, Tag ab Mitternacht. Das ergab regelmäßig
|
||||
* einen unmöglichen Vergleich — am 02.09.2026 stand in der realen Instanz
|
||||
* "Monat" UNTER "Woche", weil die Woche am Montag, dem 31.08., begann und
|
||||
* damit zwei Tage mitzählte, die der Monat nicht kennt. Zwischen Monat und
|
||||
* Jahr passiert dasselbe am Jahreswechsel.
|
||||
*
|
||||
* Rollende Fenster können das nicht: 1 ⊂ 7 ⊂ 30 ⊂ 365 gilt an jedem Tag des
|
||||
* Jahres. Der Preis ist, dass "Jahr" nicht mehr das Kalenderjahr meint,
|
||||
* sondern die letzten 365 Tage — so entschieden vom Eigentümer am 02.09.2026.
|
||||
*/
|
||||
export const ZEITRAUM_TAGE: Record<Exclude<Zeitraum, "absolut">, number> = {
|
||||
jahr: 365,
|
||||
monat: 30,
|
||||
woche: 7,
|
||||
tag: 1,
|
||||
}
|
||||
|
||||
/** Beginn des Zeitraums: so viele Tage zurück, wie ZEITRAUM_TAGE sagt. */
|
||||
export function zeitraumStart(art: Zeitraum, jetzt = new Date()): Date {
|
||||
// Vor jeder denkbaren Aufzeichnung - damit fällt nichts heraus.
|
||||
if (art === "absolut") return new Date(0)
|
||||
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
|
||||
return new Date(jetzt.getTime() - ZEITRAUM_TAGE[art] * 24 * 60 * 60 * 1000)
|
||||
}
|
||||
|
||||
export function fahrtenSeit(fahrten: readonly Fahrt[], art: Zeitraum, jetzt = new Date()): Fahrt[] {
|
||||
|
||||
@@ -400,7 +400,7 @@ export function Einstellungen({
|
||||
{/* Vorher stand hier ein Raster aller sieben Miniaturen und der Hinweis,
|
||||
Fotos seien nur über Home Assistant zu pflegen — das Panel zeigt
|
||||
genau die gewählte Ansicht groß und lädt direkt hoch. */}
|
||||
<div style={{ marginTop: "var(--sp-3)" }}>
|
||||
<div className="dm-einstellbild" style={{ marginTop: "var(--sp-3)" }}>
|
||||
<BildMitMenue
|
||||
datei={gewaehltesBild.datei}
|
||||
beschreibung={gewaehltesBild.beschreibung}
|
||||
|
||||
@@ -46,9 +46,10 @@ export function Fahrzeugstatus({ geheZu }: { geheZu: (name: SeitenName) => void
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Draufsicht, wie im Panel (.szene.bleed, "carfix klein" = 16:8) - war
|
||||
hier komplett unportiert (Parität-Audit 2026-08-30). */}
|
||||
<div className="dm-szene">
|
||||
{/* Draufsicht, wie im Panel (.szene.bleed.schmal, "carfix klein") - war
|
||||
hier komplett unportiert (Parität-Audit 2026-08-30). Seit dem
|
||||
02.09.2026 eingerückt statt randlos, siehe .dm-szene--schmal. */}
|
||||
<div className="dm-szene dm-szene--schmal">
|
||||
<Bild datei="draufsicht.webp" beschreibung="Draufsicht" name="Draufsicht" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useDaten } from "../daten/DatenKontext"
|
||||
import type { Reifensatz, ReifenArchivEintrag } from "../daten/profilAdapter"
|
||||
import { datum, de, deOderStrich } from "../format"
|
||||
import { SymbolEdit, SymbolPaket, SymbolZahnradVoll } from "../symbole"
|
||||
import { BildMitMenue, Wertzeile, Werteliste, bestaetigen } from "./bausteine"
|
||||
import { BildMitMenue, WischLoeschen, Wertzeile, Werteliste, bestaetigen } from "./bausteine"
|
||||
import { Datumsfeld } from "./Datumsfeld"
|
||||
import { kalenderDateiLaden } from "./kalender"
|
||||
|
||||
@@ -374,6 +374,7 @@ function ArchivZeile({ eintrag }: { eintrag: ReifenArchivEintrag }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<WischLoeschen beiLoeschen={() => void loeschen()}>
|
||||
<Accordion
|
||||
title={eintrag.satz === "winter" ? "Winterräder" : "Sommerräder"}
|
||||
summary={
|
||||
@@ -499,5 +500,6 @@ function ArchivZeile({ eintrag }: { eintrag: ReifenArchivEintrag }) {
|
||||
</>
|
||||
)}
|
||||
</Accordion>
|
||||
</WischLoeschen>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Fig, ProgressBar, Tile } from "@audi-dash/ui"
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { ZEITRAUM_NAME, ZEITRAUM_SPALTEN, jahresauswertung } from "../daten/statistik"
|
||||
import type { Zeitraum } from "../daten/statistik"
|
||||
import { datum, de, deOderStrich } from "../format"
|
||||
import { de, deOderStrich } from "../format"
|
||||
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
|
||||
|
||||
/** `statTile()` im Panel: Kopf mit Zahl und Nebenwert, aufklappbarer Rumpf. */
|
||||
@@ -150,39 +150,21 @@ export function Statistik() {
|
||||
titel="Langzeitverbrauch"
|
||||
wert={deOderStrich(a.langzeit.wert, 1)}
|
||||
einheit="l/100 km"
|
||||
neben={a.langzeit.bis ? `bis ${datum(a.langzeit.bis)}` : "–"}
|
||||
/* Das Datum des letzten Tankstops ist am 02.09.2026 auf Ansage des
|
||||
Eigentümers auch aus der Kopfzeile verschwunden - es war der letzte
|
||||
Rest der Herleitung, die aus dem Rumpf schon raus ist. */
|
||||
neben=""
|
||||
>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile label="Getankt insgesamt" wert={`${de(a.langzeit.liter, 1)} l`} />
|
||||
<Wertzeile
|
||||
label="Strecke bis zum letzten Tankstop"
|
||||
wert={`${de(a.langzeit.km)} km`}
|
||||
zusatz={a.langzeit.basis !== "–" ? a.langzeit.basis : undefined}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Letzter Tankstop"
|
||||
wert={a.langzeit.bis ? datum(a.langzeit.bis) : "–"}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{a.langzeit.unplausibel && (
|
||||
<span className="ads-eyebrow dm-warnton" style={{ marginTop: "16px" }}>
|
||||
Aus Litern und Strecke ergibt sich derzeit kein plausibler Wert. Sobald Tankvorgänge und
|
||||
Kilometerstände vollständig vorliegen, erscheint er hier.
|
||||
</span>
|
||||
)}
|
||||
{/* Die Herleitung des Langzeitwerts — getankte Liter, Bezugsstrecke,
|
||||
letzter Tankstop, der Unplausibel-Hinweis und der Erklärabsatz —
|
||||
ist am 02.09.2026 auf Ansage des Eigentümers aus der ANSICHT
|
||||
genommen worden, nicht aus der Rechnung: langzeitverbrauch()
|
||||
rechnet unverändert weiter und liefert liter/km/basis nach wie
|
||||
vor. Es bleiben der Wert selbst und die Näherung je Zeitraum. */}
|
||||
<Vierergruppe
|
||||
titel="Näherung je Fahrt, je Zeitraum [l/100 km]"
|
||||
werte={jeZeitraum((z) => deOderStrich(a.jeZeitraum[z].verbrauch, 1))}
|
||||
/>
|
||||
<span className="ads-eyebrow" style={{ marginTop: "16px" }}>
|
||||
Der Langzeitwert rechnet alle je getankten Liter gegen die Strecke bis zum letzten
|
||||
Tankstop. Die Werte je Zeitraum sind eine Näherung aus der Tankfüllstand-Differenz der
|
||||
einzelnen Fahrten, keine Meldung des Fahrzeugs.
|
||||
</span>
|
||||
</StatKachel>
|
||||
|
||||
<StatKachel
|
||||
|
||||
@@ -253,7 +253,10 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
|
||||
{letzteTankung && (
|
||||
<Blattzeile
|
||||
label="Letzter Tankvorgang"
|
||||
labelZusatz={letzteTankung.station_name ?? ""}
|
||||
// Bis zum 02.09.2026 stand hier der Name der Tankstelle. Das
|
||||
// Datum ist die Angabe, die auf der Startseite zählt — wann war
|
||||
// ich zuletzt tanken; der Name steht auf der Detailseite.
|
||||
labelZusatz={datum(letzteTankung.ts)}
|
||||
wert={letzteTankung.liters != null ? `${de(letzteTankung.liters, 2)} l` : ""}
|
||||
wertZusatz={
|
||||
letzteTankung.fuel_total_eur != null
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* gebraucht wird, aber zu speziell für das Design-System ist.
|
||||
*/
|
||||
|
||||
import { useRef, useState, type ReactNode } from "react"
|
||||
import { useRef, useState, type ReactNode, type TouchEvent as ReactTouchEvent } from "react"
|
||||
|
||||
import { ActionButton, ActionSheet, Tile } from "@audi-dash/ui"
|
||||
|
||||
@@ -307,6 +307,113 @@ export function StatusKreis({ ok }: { ok: boolean | null }) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Zum Löschen nach links wischen — die iOS-übliche Geste für Listenzeilen.
|
||||
*
|
||||
* Warum überhaupt: der Löschknopf des archivierten Radsatzes sitzt IN der
|
||||
* aufgeklappten Zeile. Wer löschen will, muss also erst aufklappen, an sieben
|
||||
* Eingabefeldern vorbeiscrollen und unten den Knopf finden. Auf Ansage des
|
||||
* Eigentümers am 02.09.2026.
|
||||
*
|
||||
* Bewusst NICHT die volle iOS-Mechanik: kein Löschen durch Durchwischen, die
|
||||
* Geste legt nur den Knopf frei. Ein archivierter Satz ist unwiederbringlich,
|
||||
* und ein Wisch ist zu leicht versehentlich gemacht. Die Sicherheitsabfrage
|
||||
* dahinter bleibt ebenfalls.
|
||||
*
|
||||
* Die Geste greift erst, wenn sie waagerecht deutlich stärker ist als
|
||||
* senkrecht (Faktor 1,5) — sonst nähme sie jeder Scrollbewegung die Führung
|
||||
* weg, die zufällig ein paar Pixel seitlich verläuft.
|
||||
*/
|
||||
const WISCH_BREITE = 88
|
||||
const WISCH_START = 10
|
||||
|
||||
export function WischLoeschen({
|
||||
beiLoeschen,
|
||||
beschriftung = "Löschen",
|
||||
children,
|
||||
}: {
|
||||
beiLoeschen: () => void
|
||||
beschriftung?: string
|
||||
/** Heißt `children`, weil JSX-Verschachtelung genau diesen Namen erwartet. */
|
||||
children: ReactNode
|
||||
}) {
|
||||
const [offen, setzeOffen] = useState(false)
|
||||
const [zug, setzeZug] = useState<number | null>(null)
|
||||
const start = useRef<{ x: number; y: number } | null>(null)
|
||||
const entschieden = useRef(false)
|
||||
|
||||
const versatz = zug ?? (offen ? -WISCH_BREITE : 0)
|
||||
|
||||
const beiStart = (e: ReactTouchEvent<HTMLDivElement>) => {
|
||||
if (e.touches.length !== 1) return
|
||||
start.current = { x: e.touches[0]!.clientX, y: e.touches[0]!.clientY }
|
||||
entschieden.current = false
|
||||
}
|
||||
|
||||
const beiBewegung = (e: ReactTouchEvent<HTMLDivElement>) => {
|
||||
const s = start.current
|
||||
if (!s || e.touches.length !== 1) return
|
||||
const dx = e.touches[0]!.clientX - s.x
|
||||
const dy = e.touches[0]!.clientY - s.y
|
||||
if (!entschieden.current) {
|
||||
if (Math.abs(dx) < WISCH_START) return
|
||||
// Senkrecht gemeint: die Geste gehört der Liste, nicht uns.
|
||||
if (Math.abs(dx) < Math.abs(dy) * 1.5) {
|
||||
start.current = null
|
||||
return
|
||||
}
|
||||
entschieden.current = true
|
||||
}
|
||||
const basis = offen ? -WISCH_BREITE : 0
|
||||
setzeZug(Math.max(-WISCH_BREITE, Math.min(0, basis + dx)))
|
||||
}
|
||||
|
||||
const beiEnde = () => {
|
||||
if (zug !== null) setzeOffen(zug < -WISCH_BREITE / 2)
|
||||
start.current = null
|
||||
setzeZug(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dm-wisch">
|
||||
<button
|
||||
type="button"
|
||||
className="dm-wisch__loeschen"
|
||||
tabIndex={offen ? 0 : -1}
|
||||
aria-hidden={!offen}
|
||||
onClick={() => {
|
||||
setzeOffen(false)
|
||||
beiLoeschen()
|
||||
}}
|
||||
>
|
||||
{beschriftung}
|
||||
</button>
|
||||
<div
|
||||
className="dm-wisch__inhalt"
|
||||
style={{
|
||||
transform: `translateX(${versatz}px)`,
|
||||
transition: zug === null ? "transform .18s ease" : "none",
|
||||
}}
|
||||
onTouchStart={beiStart}
|
||||
onTouchMove={beiBewegung}
|
||||
onTouchEnd={beiEnde}
|
||||
onTouchCancel={beiEnde}
|
||||
/* Ist der Knopf offen, schließt die nächste Berührung ihn wieder,
|
||||
statt die Zeile aufzuklappen - sonst wäre der einzige Weg zurück,
|
||||
erneut zu wischen. */
|
||||
onClickCapture={(e) => {
|
||||
if (!offen) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setzeOffen(false)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Fasst ok über eine Gruppe zusammen: unbekannt, sobald ein Punkt unbekannt
|
||||
ist oder die Gruppe ganz ohne Einträge dasteht (kein Sensor dieser Rolle
|
||||
zugeordnet) - nie true durch eine leere Liste erschlichen. */
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface Bildplatz {
|
||||
datei: string
|
||||
}
|
||||
|
||||
/** Die sieben Plätze der Fotogalerie unter „Mein Audi". */
|
||||
/** Die acht Plätze der Fotogalerie unter „Mein Audi". */
|
||||
export const BILDPLAETZE: Bildplatz[] = [
|
||||
{ name: "Seitenansicht", beschreibung: "Auto Seite", datei: "seitenansicht.webp" },
|
||||
{ name: "Front schräg", beschreibung: "Auto Front schräg", datei: "front-schraeg.webp" },
|
||||
@@ -28,6 +28,11 @@ export const BILDPLAETZE: Bildplatz[] = [
|
||||
{ name: "Cockpit", beschreibung: "Innenraum Cockpit", datei: "cockpit.webp" },
|
||||
{ name: "Scheinwerfer", beschreibung: "Scheinwerfer", datei: "scheinwerfer.webp" },
|
||||
{ name: "Sitze", beschreibung: "Innenraum Sitze", datei: "sitze.webp" },
|
||||
// Steht auf der Seite „Fahrzeug" über den Sicherheitszeilen. Bis zum
|
||||
// 02.09.2026 war sie der einzige sichtbare Bildplatz, der in den
|
||||
// Einstellungen NICHT auswählbar war — hochladen ließ sie sich damit gar
|
||||
// nicht (gemeldet vom Eigentümer). bilder.py kannte den Dateinamen längst.
|
||||
{ name: "Draufsicht", beschreibung: "Auto von oben", datei: "draufsicht.webp" },
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -55,7 +60,6 @@ export const WEITERE_BILDER = [
|
||||
"seitenansicht-winter.webp",
|
||||
"rad-sommer.webp",
|
||||
"rad-winter.webp",
|
||||
"draufsicht.webp",
|
||||
] as const
|
||||
|
||||
export const ERLAUBTE_DATEINAMEN: readonly string[] = [
|
||||
|
||||
@@ -48,6 +48,17 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Mittig, und zwar mit Absicht: Apples Human Interface Guidelines kennen
|
||||
genau zwei Titelformen - den großen, linksbündigen Titel auf oberster
|
||||
Ebene und den kleinen, ZENTRIERTEN in der Navigationsleiste einer
|
||||
Detailseite. Beides steht hier nebeneinander: Hauptbereiche groß und
|
||||
links, Unterseiten 17px und mittig.
|
||||
|
||||
Am 02.09.2026 kurzzeitig auf linksbündig gestellt (auf Ansage, "wie alle
|
||||
anderen auch") und auf Rückfrage des Eigentümers am selben Tag wieder
|
||||
zurückgenommen: die anderen Titel sind die der Hauptbereiche, und die
|
||||
folgen der anderen Regel. Nicht erneut ändern, ohne diese beiden Formen
|
||||
auseinanderzuhalten. */
|
||||
.dm-kopf--unterseite .dm-kopf__titel {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -323,6 +323,86 @@ input[type="date"].dm-eingabe {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Die Vorschau in den Einstellungen zeigt das ganze Foto.
|
||||
|
||||
Vorher galt der Regelkasten 4:3 mit object-fit:cover - ein Foto anderen
|
||||
Formats wurde oben und unten beschnitten, bei der Frontansicht besonders
|
||||
deutlich (gemeldet 02.09.2026). Wer prüfen will, ob das richtige Bild
|
||||
hochgeladen ist, muss es ganz sehen. Dieselbe Entscheidung wie beim
|
||||
Fahrzeugfoto der Übersicht (.carfix.klein im Panel): das Bild bestimmt die
|
||||
Höhe, nicht der Kasten. Nur der Platzhalter behält ein festes Verhältnis,
|
||||
er hat ja keines von sich aus. */
|
||||
.dm-einstellbild .ads-bildbox {
|
||||
aspect-ratio: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.dm-einstellbild .ads-bildbox img {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.dm-einstellbild .ads-bildbox:has(.ads-platzhalter) {
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
/* Die Draufsicht auf der Fahrzeugseite ist eine Beigabe, keine Szene: sie
|
||||
steht über den Sicherheitszeilen und soll die Zeilen nicht nach unten
|
||||
drängen. Statt randlos über die volle Breite (rund 374px auf einem Telefon,
|
||||
mit 16:8 also 187px hoch) steht sie eingerückt und mittig — 60% Breite, die
|
||||
Höhe geht im selben Verhältnis mit auf rund 112px zurück. Auf Ansage des
|
||||
Eigentümers am 02.09.2026 („Draufsicht-Foto Bereich ist zu groß"):
|
||||
schmaler, nicht flacher — flacher hätte bei einem Foto, das nicht sehr
|
||||
breit ist, oben und unten geschnitten. Gleichlautend .szene.bleed.schmal
|
||||
im Panel. Der :has()-Zweig oben setzt die Ränder bereits auf 0, wenn kein
|
||||
Foto da ist; hier gilt es unabhängig davon. */
|
||||
.dm-szene--schmal {
|
||||
/* Der Abstand zum Fahrzeug kommt jetzt aus dem Layout, nicht aus leerem
|
||||
Bildrand: das Foto ist seit dem 02.09.2026 freigestellt und eng
|
||||
beschnitten. Oben und unten Luft wie im Vorbild des Eigentümers. */
|
||||
margin: 0;
|
||||
padding: 22px 0 26px;
|
||||
}
|
||||
|
||||
.dm-szene--schmal .ads-bildbox {
|
||||
width: 60%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Zum Löschen nach links wischen (WischLoeschen in bausteine.tsx).
|
||||
|
||||
Der Knopf liegt fest hinter dem Inhalt, verschoben wird nur der Inhalt
|
||||
darüber — deshalb braucht er einen DECKENDEN Grund, sonst schiene das Rot
|
||||
hindurch. --tile ist der Grund der Kachel, in der die Zeile sitzt.
|
||||
|
||||
touch-action: pan-y sagt dem Browser, dass die waagerechte Achse uns
|
||||
gehört: senkrecht scrollt er weiter selbst, waagerecht wartet er auf uns,
|
||||
statt die Geste nach ein paar Pixeln zu übernehmen. */
|
||||
.dm-wisch {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dm-wisch__loeschen {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 88px;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: var(--bad);
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dm-wisch__inhalt {
|
||||
position: relative;
|
||||
background: var(--tile);
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.dm-szene .ads-bild,
|
||||
.dm-szene img {
|
||||
width: 100%;
|
||||
@@ -411,10 +491,25 @@ button.dm-serviceblock:active {
|
||||
padding-right: 44px;
|
||||
}
|
||||
|
||||
/* Das Zahnrad ist 44px hoch (Trefferfläche), die Überschrift darunter nur
|
||||
12px. Ohne reservierte Zeile ragte es also 20px in den Kachelinhalt und lag
|
||||
sichtbar auf dem ersten Knopf - auf der Versicherungsseite auf "Inland ·
|
||||
<Nummer>", von dort auch gemeldet (02.09.2026). Es fing dessen obere Hälfte
|
||||
ab: der Knopf ließ sich am oberen Rand nicht wählen.
|
||||
|
||||
Statt das Zahnrad zu verkleinern (44px sind die Mindestgröße einer
|
||||
Trefferfläche) bekommt die Kopfzeile die Höhe, die es braucht. Gleiche
|
||||
Regel im Panel (.tile:has(> .zahnrad)). */
|
||||
.dm-kachel--zahnrad > .ads-eyebrow:first-child,
|
||||
.dm-kachel--zahnrad > .ads-title:first-child {
|
||||
min-height: 34px;
|
||||
padding-right: 48px;
|
||||
}
|
||||
|
||||
.dm-zahnrad {
|
||||
position: absolute;
|
||||
top: 11px;
|
||||
right: 11px;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: inline-flex;
|
||||
|
||||
@@ -154,17 +154,26 @@ export function SymbolPaket({ groesse }: SymbolProps) {
|
||||
)
|
||||
}
|
||||
|
||||
/* fuel-station-s aus dem Audi-CI-Satz (24er-Raster), verbatim - derselbe
|
||||
Pfad wie CI.fuelStationS im Panel. Ersetzt seit dem 02.09.2026 eine
|
||||
handgezeichnete Zapfsäule.
|
||||
|
||||
Der Faktor 0,84 um die Rastermitte hat denselben Grund wie bei
|
||||
SymbolStatistik: das CI-Symbol ist randlos gezeichnet und stünde
|
||||
ungeskaliert mit 22 von 24 Einheiten Höhe als höchstes Symbol der Leiste
|
||||
da. Skaliert sind es 15,96 x 18,48 - dieselbe senkrechte Spanne wie
|
||||
Statistik (y 2,76 bis 21,24), live per getBBox() gemessen, Ränder
|
||||
eingeschlossen.
|
||||
|
||||
Die 48er-Variante hieße fuel-station-l und wird nicht gebraucht: die
|
||||
Reiter zeichnen mit 21 bzw. 26px, also im 24er-Raster. */
|
||||
export function SymbolTanken({ groesse }: SymbolProps) {
|
||||
return (
|
||||
<Rahmen
|
||||
<RahmenFlaeche
|
||||
groesse={groesse}
|
||||
kinder={
|
||||
<g>
|
||||
<path d="M4.5 20V6.2c0-.9.8-1.7 1.7-1.7h5.6c1 0 1.7.8 1.7 1.7V20" />
|
||||
<path d="M3 20h12.5" />
|
||||
<path d="M4.5 11h10.5" />
|
||||
<path d="M17 20v-8.5a2 2 0 0 0-2-2" />
|
||||
<path d="M17 11.5V7.8l-2-2.3" />
|
||||
<g transform="translate(1.5,1.92) scale(0.84)">
|
||||
<path fill="currentColor" d="M4 3.5A2.5 2.5 0 0 1 6.5 1h6A2.5 2.5 0 0 1 15 3.5V10h.5a2.5 2.5 0 0 1 2.5 2.5v5a1.5 1.5 0 0 0 3 0V8.07a16.4 16.4 0 0 0-2.89-5.76l.78-.62a17.4 17.4 0 0 1 3.1 6.17l.01.07v9.57a2.5 2.5 0 0 1-5 0v-5c0-.83-.67-1.5-1.5-1.5H15v11h1v1H3v-1h1zM5 22h9V3.5c0-.83-.67-1.5-1.5-1.5h-6C5.67 2 5 2.67 5 3.5zm1-12h7v1H6z" />
|
||||
</g>
|
||||
}
|
||||
/>
|
||||
@@ -175,23 +184,24 @@ export function SymbolZurueck({ groesse }: SymbolProps) {
|
||||
return <Rahmen groesse={groesse} kinder={<path d="M15 5l-7 7 7 7" />} />
|
||||
}
|
||||
|
||||
export function SymbolZahnrad({ groesse }: SymbolProps) {
|
||||
return (
|
||||
<Rahmen
|
||||
groesse={groesse}
|
||||
kinder={
|
||||
<g>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" />
|
||||
</g>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/* Hier stand bis zum 02.09.2026 SymbolZahnrad: ein selbst gezeichnetes
|
||||
Zahnrad (Kreis mit acht geraden Speichen), das nur in der Seitenleiste des
|
||||
Flächenlayouts auftauchte - auf einem Telefon also nie. Ersetzt durch
|
||||
settings-s aus dem CI-Satz, dasselbe Symbol wie in den Kachel-Ecken. Es
|
||||
stand ohnehin schon neben drei CI-Flächensymbolen (Mein Audi, Fahrten,
|
||||
Statistik); das gezeichnete war der Ausreißer, nicht umgekehrt. */
|
||||
|
||||
/* Das Zahnrad der Kachel-Ecken (`.zahnrad` im Panel) — ein gefülltes Symbol
|
||||
aus dem Audi-CI-Satz, verbatim übernommen. Nicht zu verwechseln mit
|
||||
SymbolZahnrad oben, dem Kontur-Symbol der Seitenleiste. */
|
||||
/* settings-s aus dem Audi-CI-Satz (24er-Raster), verbatim - dieselbe Datei
|
||||
wie design/assets/icons/settings.svg und derselbe Pfad wie CI.settingsS im
|
||||
Panel. Steht in der rechten oberen Ecke einer Kachel (`.zahnrad` dort).
|
||||
|
||||
Die 48er-Variante hieße settings-l; sie wird nirgends gebraucht, das
|
||||
Zahnrad wird immer 17px dargestellt.
|
||||
|
||||
"Voll" im Namen meint die Fläche des CI-Symbols (ein gefüllter Pfad mit
|
||||
Aussparungen), nicht eine gefüllte Variante der Bibliothek. Nicht zu
|
||||
Bibliothek. Seit dem 02.09.2026 auch in der Seitenleiste des
|
||||
Flächenlayouts - ein zweites, gezeichnetes Zahnrad gibt es nicht mehr. */
|
||||
export function SymbolZahnradVoll({ groesse }: SymbolProps) {
|
||||
return (
|
||||
<RahmenFlaeche
|
||||
|
||||
Reference in New Issue
Block a user