Termine ohne Gedaechtnis, Listen nach HIG, zwei zu schmale Felder (2026.9.2.13-.20)

TERMIN. Der Werkstatttermin wanderte bisher ins Fahrzeugprofil, stand als
Merkmal an der Karte und liess sich "verwerfen" - eine zweite Verwaltung neben
dem Kalender. Auf Ansage entfernt: er dient jetzt allein dem Kalendereintrag
"was, wann, bei welcher Werkstatt". Dabei aufgefallen: die App schrieb gar kein
LOCATION in die .ics, obwohl das Panel es seit jeher setzt.

Ohne Speicherung startete das Feld leer ("tt.mm.jjjj", so gemeldet). Es
schlaegt jetzt die Faelligkeit der gewaehlten Art vor, sonst heute - dafuer
liefert service.ts serviceKandidaten(). "Andere" ergaenzt die Auswahl.

BREITEN. Am laufenden Panel gemessen: die vier festen 132px-Datumsfelder
brauchen ab 140px, damit die letzte Ziffer nicht unter dem Kalendersymbol
liegt (jetzt 150px); die Art-Auswahl braucht 178px fuer
"Hauptuntersuchung" (jetzt 190px). In der App war nichts davon kaputt - eine
dort vorsorglich eingebaute Breite ist wieder entfernt.

LISTEN. Fahrten- und Tankliste verloren 58px an tote Breite: 22px Einrueckung
der Monatsebene plus 40px linkes Polster der Zeile. Auf dem Telefon brach
"Eichstaett -> Adelschlag" deshalb um. Apples Richtlinien gruppieren ueber
Abschnittskoepfe statt Einrueckung; die Zeilen stehen jetzt alle auf derselben
Kante (34 statt 96px), der Monat traegt Versalien und Sperrung und liest sich
als Kopf. Beschraenkt auf .liste/.dm-liste - anderswo ist das Polster gewollt.

BILDER. Die Draufsicht ist aus der Galerie genommen (nurStatus), gehoert aber
weiter in die Auswahl der Einstellungen: nur dort laesst sie sich hochladen.
Die Galerie schneidet Fotos nicht mehr an - die Frontansicht verlor unten die
Stossstange.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 16:36:25 +02:00
parent 38e7731a3d
commit d889353cbe
14 changed files with 363 additions and 76 deletions
+97
View File
@@ -8187,3 +8187,100 @@ Statistik**: y 2,76 bis 21,24. Ränder nachgemessen, nicht nur die Größe — d
Übersicht-Symbol vom selben Tag.
`fuel-station-l` (48er-Raster) wird nicht gebraucht: die Reiter zeichnen mit 21 bzw. 26px.
---
## BQ. Termine, Listen und was sich im laufenden Panel messen lässt (2026.9.2.13-.20)
Ein Stapel Meldungen des Eigentümers am Nachmittag des 02.09.2026. Der rote Faden: diesmal ließ
sich das meiste **am laufenden Panel messen** statt zu rechnen — der Browser dieser Sitzung hatte
die Testinstanz angemeldet offen, und über die Shadow-Roots von Home Assistant kommt man an jedes
Element (`getBBox()`, `getComputedStyle()`, Bildschirmfotos). Das ist der Weg, der in diesem Projekt
schon zweimal Größenfehler verhindert hätte; er steht hier, damit er nicht wieder vergessen wird.
### Der Werkstatttermin hatte ein Gedächtnis, das niemand wollte (.14)
Jedes eingetragene Datum wanderte ins Fahrzeugprofil (`service.vereinbart`), erschien als Merkmal
an der Terminkarte und ließ sich über einen eigenen Knopf „verwerfen" — eine **zweite
Terminverwaltung neben dem Kalender**, die auseinanderlief, sobald der Termin dort verschoben wurde.
Auf Ansage entfernt: kein Schreiben mehr (weder im Feld noch beim Export), kein Merkmal, kein
Verwerfen-Knopf. Der Termin hat jetzt genau einen Zweck — dem Kalender zu sagen, **was** wann und
bei **welcher Werkstatt**.
Dabei ist ein echter Mangel aufgefallen: die App schrieb **gar kein `LOCATION`** in die .ics. Der
Termin sagte „was" und „wann", aber nicht „wo", obwohl das Panel es seit jeher setzt. Jetzt tragen
beide Autohausname als Ort und Anschrift als Beschreibung.
### Und der Vorschlag, den ich dabei kaputt gemacht habe (.18)
Ohne Speicherung startete das Datumsfeld **leer** — auf dem Telefon als „tt.mm.jjjj" sichtbar, und
genau so gemeldet („Datums-Textfeld kaputt bei Hauptuntersuchung"; bei Ölwechsel und Inspektion
stand noch ein gespeicherter Termin drin). Der Fehler war meiner, aus demselben Nachmittag.
Jetzt schlägt das Feld die **Fälligkeit der gewählten Art** vor — dieselbe Zahl wie auf den Karten
darüber, derselbe Vorschlag wie im Panel; fehlt sie (Hauptuntersuchung ohne HU-Datum, „Andere"),
steht heute im Feld. Leer wird es nie. Dafür liefert `service.ts` jetzt `serviceKandidaten()`, alle
drei Arten mit ihrer Fälligkeit; `naechsterService()` nimmt davon weiterhin die früheste.
### Zwei Felder, die zu schmal waren — gemessen, nicht geschätzt (.17)
Im Panel standen vier Datumsfelder und die Art-Auswahl auf fest verdrahteten **132px**. Am
laufenden Panel nachgemessen (Audi Type 16px):
| | braucht | stand auf | jetzt |
|---|---|---|---|
| „24.07.2027" + Kalendersymbol | ab 140px frei | 132px | **150px** |
| „Hauptuntersuchung" + Chevron | 150,4 + 28 = 178px | 132px | **190px** |
Beim Datum lag die letzte Ziffer über dem Symbol (Probereihe 132/140/150/160/170/180 nebeneinander
eingesetzt und angesehen), bei der Auswahl stand „Hauptuntersuchur". Beide Zeilen passen auch auf
375px Telefonbreite.
**In der App war nichts davon kaputt** — dort sitzt kein festes Maß, und der Eigentümer hat am Gerät
gegengeprüft. Eine vorsorglich eingebaute Breite dort ist wieder entfernt worden: eine Änderung ohne
Befund ist keine Verbesserung.
### Die Fahrtenliste: 58px tote Breite (.19/.20)
Gemeldet mit Bildschirmfoto vom iPhone: „Eichstätt → Adelschlag" brach um. Gemessen, warum:
| | |
|---|---|
| 22px | Einrückung der Monatsebene (`.lvl2`) |
| 40px | linkes Polster jeder Zeile (`.leaf`/`.dm-blatt`) |
Zusammen 58px von rund 300px Kachelbreite, für einen Ortsnamen blieben ~150px.
**Apples Richtlinien gruppieren nicht über Einrückung, sondern über Abschnittsköpfe über die volle
Breite**; die Zeilen stehen alle auf derselben Kante. Genau so jetzt — beschränkt auf `.liste` /
`.dm-liste`, weil das linke Polster anderswo (Batteriewerte, Servicebuch, Zuletzt-Kachel) gewollt
ist. Nachgemessen auf 375px: Fahrtzeile von 96px auf **34px** eingerückt, der Ortsname steht
einzeilig.
Der Monat war danach 13px grau und die Zeile 16px schwarz — dazu die Frage des Eigentümers, ob das
so gehöre. Es gehört so (iOS macht Abschnittsköpfe kleiner als den Inhalt), aber die Liste wirkte
zweistufig. Der Monat trägt jetzt **11px, Versalien, 0,18em Sperrung** wie die übrigen Eyebrows —
drei erkennbare Stufen, ohne dass der Kopf Platz kostet. 11 statt 10px: die Untergrenze dieses
Projekts aus den Apple-Richtlinien.
### Kleinigkeiten aus demselben Stapel
- **„Andere"** in der Auswahl „Wofür" (.13). Im Panel etwas mehr als ein Listeneintrag: die Auswahl
entsteht dort aus `termine()`, also aus **fälligen** Terminen mit Ziel und Kilometerstand.
„Andere" ist keiner davon — dafür `terminAuswahl()`/`gewaehlterTermin()`, die beide Stellen
bedienen, die vorher dieselbe Zeile doppelt hatten.
- **Draufsicht nicht mehr in der Galerie** (.13): neues Kennzeichen `nurStatus` am Bildplatz. In der
Auswahl der Einstellungen bleibt sie — sie ist der einzige Weg, das Foto hochzuladen.
- **Die Galerie schneidet nicht mehr an** (.15): sie stand als letzte im festen 16:9-Kasten mit
`object-fit: cover`, die Frontansicht verlor unten die Stoßstange. Jetzt bestimmt das Bild die
Höhe, wie Übersicht-Szene und Einstellungs-Vorschau.
- **Zusatz „vereinbart"** am Wechseltermin entfernt (.16) — sein Schalter war am selben Tag von der
Räder-Seite verschwunden, das Feld blieb ungelesen im Profil zurück.
### Offen aus diesem Stapel
- **Teilen legt keinen Tankvorgang an** (gemeldet am Gerät). Noch nicht untersucht.
- **Kopieren/Einfügen klappt am iPhone nicht.**
- **Hochladen über einen eigenen HTTP-Endpunkt** statt über den Dienst: hebt die 3-MB-Grenze des
Panels auf (WebSocket, aiohttp-Vorgabe 4 MiB) und spart die Base64-Aufblähung. Gemessen: über
REST gehen 12 MB durch, über den WebSocket bricht die Verbindung ab 3,2 MB Rohbild wortlos ab.
+22 -3
View File
@@ -275,6 +275,27 @@ export function naechsterService(
fahrten: readonly Fahrtdaten[] = [],
jetzt = new Date(),
): NaechsterService | null {
const kandidaten = serviceKandidaten(fahrzeug, buch, fahrten, jetzt)
if (kandidaten.length === 0) return null
// ISO-Tagesdaten, deshalb reicht der Zeichenkettenvergleich.
return kandidaten.slice().sort((a, b) => a.datum.localeCompare(b.datum))[0] ?? null
}
/**
* Alle drei Arten mit ihrer voraussichtlichen Fälligkeit, ungeordnet.
*
* `naechsterService()` nimmt davon die früheste. Getrennt herausgezogen, weil
* das Terminfeld auf der Serviceseite die Fälligkeit **je gewählter Art**
* vorschlägt - so wie `termine()` im Panel, das dort dieselbe Liste liefert.
* Eine Art ohne Grundlage (Hauptuntersuchung ohne gepflegtes HU-Datum) fehlt
* in der Liste; der Aufrufer entscheidet, was er stattdessen anbietet.
*/
export function serviceKandidaten(
fahrzeug: Fahrzeug,
buch: readonly Buchhaltung[],
fahrten: readonly Fahrtdaten[] = [],
jetzt = new Date(),
): NaechsterService[] {
const kandidaten: NaechsterService[] = []
const oel = oelwechselPrognose(fahrzeug, buch, jetzt)
@@ -303,7 +324,5 @@ export function naechsterService(
})
}
if (kandidaten.length === 0) return null
// ISO-Tagesdaten, deshalb reicht der Zeichenkettenvergleich.
return kandidaten.sort((a, b) => a.datum.localeCompare(b.datum))[0] ?? null
return kandidaten
}
+1 -1
View File
@@ -77,7 +77,7 @@ export function Fahrten({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
/>
</Tile>
) : (
<Tile>
<Tile className="dm-liste">
{gruppen.map((jahr) => (
<Accordion
key={jahr.jahr}
+10 -5
View File
@@ -23,7 +23,7 @@ import { datum, de, deOderStrich, eur } from "../format"
import type { SeitenName } from "../navigation"
import { Wertzeile, Werteliste } from "./bausteine"
import { Bild } from "./Bild"
import { BILDPLAETZE } from "./bilder"
import { GALERIE } from "./bilder"
import type { ServicebuchEintrag } from "./Service"
import { Typenschild, schildErkennen } from "./Typenschild"
@@ -40,7 +40,7 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) =
const aktiverSatz =
fahrzeug.reifen.aktiv === "Winter" ? fahrzeug.reifen.winter : fahrzeug.reifen.sommer
const platz = BILDPLAETZE[bildIndex % BILDPLAETZE.length]!
const platz = GALERIE[bildIndex % GALERIE.length]!
const ausstattung = Array.isArray(fahrzeug.ausstattung)
? (fahrzeug.ausstattung as AusstattungGruppe[])
@@ -106,13 +106,13 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) =
<button
type="button"
className="dm-galerie-wechsel"
onClick={() => setzeBildIndex((i) => (i + 1) % BILDPLAETZE.length)}
onClick={() => setzeBildIndex((i) => (i + 1) % GALERIE.length)}
aria-label="Nächstes Bild"
>
<Bild datei={platz.datei} beschreibung={platz.beschreibung} name={platz.beschreibung} />
</button>
<div className="dm-galerie-punkte" aria-hidden="true">
{BILDPLAETZE.map((b, i) => (
{GALERIE.map((b, i) => (
<span key={b.datei} className={i === bildIndex ? "an" : ""} />
))}
</div>
@@ -239,10 +239,15 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) =
<>
<Wertzeile label="aktuell montiert:" wert={`${fahrzeug.reifen.aktiv}räder`} />
<Wertzeile label="Laufleistung" wert={deOderStrich(aktiverSatz.km) + " km"} />
{/* Ohne Zusatz "vereinbart": das Merkmal kam aus
reifen.wechsel.vereinbart, dessen Schalter am 02.09.2026 von der
Raeder-Seite entfernt wurde. Es liess sich seither nicht mehr
setzen und stand nur noch an Altbestaenden - im Panel war die
entsprechende Bedingung schon tot. Das Feld selbst bleibt im
Profil, es wird nur nirgends mehr gelesen. */}
<Wertzeile
label="Wechseltermin"
wert={datum(fahrzeug.reifen.wechsel["datum"] as string)}
zusatz={fahrzeug.reifen.wechsel["vereinbart"] ? "vereinbart" : undefined}
/>
</>
}
+54 -28
View File
@@ -20,6 +20,7 @@ import {
meldungsPrognose,
oelMeldungsPrognose,
oelwechselPrognose,
serviceKandidaten,
} from "../daten/service"
import { datum, de, eur, isoTag } from "../format"
import type { SeitenName } from "../navigation"
@@ -248,47 +249,63 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
)
}
/** `Termin vereinbaren` aus vService(): Art wählen, Datum setzen, ICS laden. */
/**
* `Termin vereinbaren` aus vService(): Art wählen, Datum setzen, ICS laden.
*
* DER TERMIN WIRD NICHT GESPEICHERT. Bis zum 02.09.2026 wanderte jedes
* eingetragene Datum ins Fahrzeugprofil (`service.vereinbart`), erschien als
* Merkmal an der Terminkarte und ließ sich über einen eigenen Knopf wieder
* "verwerfen" - eine zweite Verwaltung neben dem Kalender, die niemand
* gebeten hatte und die auseinanderlaufen konnte, sobald der Termin im
* Kalender verschoben wurde.
*
* Auf Ansage des Eigentümers hat der Termin jetzt genau einen Zweck: dem
* Kalender zu sagen, **was** wann und bei **welcher Werkstatt** ansteht. Das
* Datum lebt deshalb nur, solange diese Ansicht offen ist.
*/
function TerminVereinbaren() {
const { fahrzeug, profilSpeichern } = useDaten()
const { fahrzeug, einstellungen, fahrten } = useDaten()
const [art, setzeArt] = useState("Ölwechsel")
const vereinbart = (fahrzeug?.service["vereinbart"] ?? {}) as Record<string, string>
const [datumWert, setzeDatumWert] = useState(() => vereinbart[art] ?? "")
/* null = "noch nichts von Hand geändert", dann gilt der Vorschlag unten.
Sobald jemand ein Datum wählt, bleibt es stehen, auch wenn die Art
wechselt - sonst würde die eigene Eingabe stillschweigend überschrieben. */
const [eigenesDatum, setzeEigenesDatum] = useState<string | null>(null)
if (!fahrzeug) return null
const merken = async (neuesDatum: string) => {
setzeDatumWert(neuesDatum)
await profilSpeichern({
fahrzeug: {
...fahrzeug,
service: { ...fahrzeug.service, vereinbart: { ...vereinbart, [art]: neuesDatum } },
},
})
const autohaus = (fahrzeug.service["autohaus"] ?? {}) as {
name?: string
adresse?: string
}
const verwerfen = async () => {
setzeDatumWert("")
const rest = { ...vereinbart }
delete rest[art]
await profilSpeichern({
fahrzeug: { ...fahrzeug, service: { ...fahrzeug.service, vereinbart: rest } },
})
}
/* Vorgeschlagen wird die Fälligkeit der gewählten Art - dieselbe Zahl, die
die Karten oben zeigen, und derselbe Vorschlag wie im Panel. Fehlt sie
(Hauptuntersuchung ohne gepflegtes HU-Datum, "Andere"), steht heute im
Feld: ein leeres Datumsfeld sieht auf dem Telefon aus wie ein Fehler
("tt.mm.jjjj"), und genau so wurde es gemeldet. */
const buch = (fahrzeug.service["buch"] ?? []) as ServicebuchEintrag[]
const kandidaten = serviceKandidaten(fahrzeug, buch, fahrten)
const vorschlag = kandidaten.find((k) => k.art === art)?.datum ?? isoTag()
const datumWert = eigenesDatum ?? vorschlag
return (
<Tile>
<span className="ads-eyebrow">Termin vereinbaren</span>
<Feld label="Wofür">
{/* Keine eigene Breite: "Hauptuntersuchung" passt hier auch in die 60 %
aus .dm-auswahl - am Geraet nachgesehen (Eigentuemer, 02.09.2026).
Abgeschnitten wurde es nur im Panel, wo das Feld auf festen 132px
stand; dort jetzt 190px. */}
<select
className="dm-auswahl"
value={art}
onChange={(e) => {
setzeArt(e.target.value)
setzeDatumWert(vereinbart[e.target.value] ?? "")
}}
onChange={(e) => setzeArt(e.target.value)}
>
{["Ölwechsel", "Inspektion", "Hauptuntersuchung"].map((x) => (
{/* "Andere" ist kein fälliger Termin, sondern ein freier Werkstattbesuch,
der zu keiner der drei geplanten Arten gehört (Reifenwechsel,
Garantiearbeit, Nachbesserung). Er hat deshalb kein Ziel und
keine Kilometerangabe - nur ein Datum. */}
{["Ölwechsel", "Inspektion", "Hauptuntersuchung", "Andere"].map((x) => (
<option key={x} value={x}>
{x}
</option>
@@ -296,14 +313,23 @@ function TerminVereinbaren() {
</select>
</Feld>
<Feld label="Werkstatttermin am" last>
<Datumsfeld wert={datumWert} beiAenderung={(w) => void merken(w)} />
<Datumsfeld wert={datumWert} beiAenderung={setzeEigenesDatum} />
</Feld>
{/* Name des Autohauses als Ort, Anschrift als Beschreibung - wie im
Panel (`ics()` dort). Fehlt das Autohaus, bleiben beide Felder leer
und der Termin trägt nur "was" und "wann". */}
<ActionButton
onClick={() => void kalenderDateiLaden(art, datumWert || isoTag(), `${art} in der Werkstatt`)}
onClick={() =>
void kalenderDateiLaden(
`${einstellungen?.fahrzeugtitel ?? "Fahrzeug"}: ${art}`,
datumWert,
autohaus.adresse ?? "",
autohaus.name ?? "",
)
}
>
In den Kalender übernehmen
</ActionButton>
{vereinbart[art] && <ActionButton onClick={() => void verwerfen()}>Termin verwerfen</ActionButton>}
</Tile>
)
}
+1 -1
View File
@@ -53,7 +53,7 @@ export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
/>
</Tile>
) : (
<Tile>
<Tile className="dm-liste">
{gruppen.map((jahr) => {
const ersparnisJahr = summe(jahr.eintraege, (t) => t.discount ?? 0)
return (
+13 -2
View File
@@ -17,9 +17,17 @@ export interface Bildplatz {
*/
beschreibung: string
datei: string
/**
* Nicht in der Fotogalerie unter „Mein Audi" zeigen.
*
* Die Draufsicht gehört zur Fahrzeugstatus-Seite und nur dorthin (Ansage
* des Eigentümers, 02.09.2026). In der Auswahl der Einstellungen bleibt sie
* trotzdem stehen - sie ist der einzige Weg, das Foto hochzuladen.
*/
nurStatus?: boolean
}
/** Die acht Plätze der Fotogalerie unter „Mein Audi". */
/** Alle Bildplätze. Die Galerie zeigt davon nur GALERIE, siehe nurStatus. */
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" },
@@ -32,9 +40,12 @@ export const BILDPLAETZE: Bildplatz[] = [
// 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" },
{ name: "Draufsicht", beschreibung: "Auto von oben", datei: "draufsicht.webp", nurStatus: true },
]
/** Die Plätze, die die Galerie unter „Mein Audi" durchblättert. */
export const GALERIE: Bildplatz[] = BILDPLAETZE.filter((b) => !b.nurStatus)
/**
* Bei montierten Winterrädern zeigt die Seitenansicht das Winterfoto —
* `SEITE_WINTER`/`bildInfo()` im Panel. Fehlte hier bisher ganz.
+16 -2
View File
@@ -29,7 +29,19 @@ function alsIcsDatum(isoTag: string): string {
return isoTag.replace(/-/g, "")
}
export function kalenderDatei(titel: string, isoTag: string, beschreibung = ""): string {
/**
* `ort` wird zu LOCATION - die Kalender-Apps zeigen das als Ortsangabe und
* bieten die Navigation dorthin an. Fehlte hier bis zum 02.09.2026 ganz,
* obwohl das Panel es seit jeher setzt (`ics()` dort): der Termin sagte
* "was" und "wann", aber nicht "wo" - und genau das ist neben dem Datum der
* einzige Zweck, den ein Werkstatttermin in dieser App hat.
*/
export function kalenderDatei(
titel: string,
isoTag: string,
beschreibung = "",
ort = "",
): string {
const stempel = new Date().toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"
const start = alsIcsDatum(isoTag)
const ende = alsIcsDatum(
@@ -47,6 +59,7 @@ export function kalenderDatei(titel: string, isoTag: string, beschreibung = ""):
`DTSTART;VALUE=DATE:${start}`,
`DTEND;VALUE=DATE:${ende}`,
`SUMMARY:${zeilenUmbruch(titel)}`,
ort ? `LOCATION:${zeilenUmbruch(ort)}` : "",
beschreibung ? `DESCRIPTION:${zeilenUmbruch(beschreibung)}` : "",
"BEGIN:VALARM",
"TRIGGER:-P2D",
@@ -74,8 +87,9 @@ export async function kalenderDateiLaden(
titel: string,
isoTag: string,
beschreibung = "",
ort = "",
): Promise<void> {
const inhalt = kalenderDatei(titel, isoTag, beschreibung)
const inhalt = kalenderDatei(titel, isoTag, beschreibung, ort)
const name = dateiname(titel)
if (Capacitor.isNativePlatform()) {
+54 -3
View File
@@ -916,10 +916,21 @@ button.dm-serviceblock:active {
padding: 0;
cursor: pointer;
}
/* 16:9, wie carImg("galerie") im Panel ("carfix", nicht "carfix klein" -
das 16:8-Maß gilt nur für die Übersicht-Szene). */
/* Das Bild bestimmt die Höhe, wie im Panel ("carfix klein").
Bis zum 02.09.2026 stand hier ein fester 16:9-Kasten mit object-fit:cover.
Jedes Foto, dessen Verhältnis davon abweicht, verlor dabei oben und unten
etwas - bei der Frontansicht die Stoßstange, so gemeldet. Der Preis ist,
dass der Block beim Durchblättern seine Höhe ändert; das ist dieselbe
Abwägung, die die Übersicht-Szene am 31.08. entschieden hat. Der
Platzhalter behält sein festes Maß (Regel weiter oben), er hat ja kein
eigenes Verhältnis. */
.dm-galerie-wechsel .ads-bildbox {
aspect-ratio: 16 / 9;
aspect-ratio: auto;
}
.dm-galerie-wechsel .ads-bildbox img {
height: auto;
}
.dm-galerie-punkte {
display: flex;
@@ -1064,6 +1075,46 @@ button.dm-sicherheitszeile:active { background: var(--tile-2); }
gemessen): 12px oben/unten, 40px links (Platz der Wischspalte), min-height
56px, Trennlinie unten, Beschriftung 16px in --fg mit 12,5px-Zusatz in
--fg3, Wert 15px in --fg2 mit demselben Zusatz. */
/* Fahrten- und Tankliste: Abschnittsköpfe statt Einrückung.
Gemessen am 02.09.2026 im laufenden Panel (gleicher Aufbau): von der
Innenkante der Kachel bis zum Ortsnamen lagen 58px tote Breite - 22px
Einrückung der Monatsebene plus 40px linkes Polster der Zeile. Auf einem
Telefon brach "Eichstätt → Adelschlag" deshalb um.
Apples Richtlinien gruppieren nicht über Einrückung, sondern über
Abschnittsköpfe über die volle Breite; die Zeilen stehen alle auf
derselben Kante. Die Monatszeile bleibt kleiner und grau und liest sich
dadurch als Kopf, ohne Platz zu kosten. Beschränkt auf .dm-liste, weil das
linke Polster von .dm-blatt anderswo gewollt ist. Gleichlautend .liste im
Panel. */
.dm-liste .ads-accordion--lvl2 {
margin-left: 0;
}
/* Der Monat ist ein Abschnittskopf, kein kleingeratener Eintrag.
Vorher unterschied er sich von der Fahrtzeile nur durch Größe (13 gegen
16px) und Farbe - beim Lesen wirkte die Liste dadurch zweistufig statt
dreistufig, und genau danach hat der Eigentümer gefragt (02.09.2026).
Versalien plus Sperrung sind die Form, die iOS für Abschnittsköpfe
benutzt und die in dieser Oberfläche schon über dem Seitentitel steht
(.ads-eyebrow).
11px und nicht 10: das ist die Untergrenze, die dieses Projekt aus den
Apple-Richtlinien übernommen hat. In beiden Oberflächen dieselben Werte,
damit der Kopf nicht je Gerät anders aussieht. */
.dm-liste .ads-accordion--lvl2 .ads-acc-h__k {
font-size: 11px;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--fg3);
}
.dm-liste .dm-blatt {
padding-left: 0;
}
.dm-blatt {
display: flex;
align-items: center;
@@ -1 +1 @@
{"version":"2026.9.2.12","sha256":"2d28d29f526a130188efe816fc5cb8054f3f1afaf86f61eb371dc617753de29c","bytes":266536,"gebaut":"2026-09-02T13:40:40Z"}
{"version":"2026.9.2.20","sha256":"04aa82b37c019eadf0c9c23bc27e4931b9371dba512ddd979adc1ea70f482924","bytes":266578,"gebaut":"2026-09-02T14:34:34Z"}
@@ -344,8 +344,14 @@ const BILDER = [
// 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", label: "Auto von oben", datei: "draufsicht.webp" },
/* nurStatus: gehoert zur Fahrzeugseite und nur dorthin (Ansage des
Eigentuemers, 02.09.2026) - die Galerie unter "Mein Audi" ueberspringt
sie, in der Auswahl der Einstellungen bleibt sie stehen, weil das der
einzige Weg ist, das Foto hochzuladen. */
{ name: "Draufsicht", label: "Auto von oben", datei: "draufsicht.webp", nurStatus: true },
];
/* Die Plaetze, die die Galerie durchblaettert. */
const GALERIE = BILDER.filter((b) => !b.nurStatus);
let bild = 0;
const SEITE_WINTER = { label: "Auto Seite mit Winterrädern", datei: "seitenansicht-winter.webp" };
// Bei jedem Hochladen/Löschen eines Fahrzeugfotos hochgezählt und als
@@ -403,9 +409,14 @@ function carImg(modus) {
.bildbox.bild-fehlt img{display:none} unsichtbar und damit unklickbar
ist - der bisherige Handler (id="carimg") griff dann ins Leere. */
return `<div data-bildgalerie>
${bildMitPlatzhalter(info.src, info.datei, info.label, "carfix", "carimg")}
${/* "klein": das Bild bestimmt die Hoehe. Im festen 16:9-Kasten mit
object-fit:cover verlor die Frontansicht unten die Stossstange
(gemeldet 02.09.2026) - jedes Foto, dessen Verhaeltnis nicht 16:9
ist, wurde beschnitten. Dieselbe Entscheidung wie bei der
Uebersicht-Szene (31.08.) und der Vorschau in den Einstellungen. */""
}${bildMitPlatzhalter(info.src, info.datei, info.label, "carfix klein", "carimg")}
</div>
<div class="dots" id="dots">${BILDER.map((_, i) => `<i class="${i === bild ? "on" : ""}"></i>`).join("")}</div>`;
<div class="dots" id="dots">${GALERIE.map((b) => `<i class="${b === BILDER[bild] ? "on" : ""}"></i>`).join("")}</div>`;
}
/* Bildmarke des Kraftstoffanbieters (Shell). Anders als bei den Fahrzeug-
fotos ist hier kein generischer Platzhalter-Rahmen sinnvoll, weil das Logo
@@ -430,7 +441,11 @@ function markenlogo() {
an .szene haengte; die zugehoerigen ::after-Regeln sind in
audi-dashboard.css beschrieben. */
function bildWeiter() {
bild = (bild + 1) % BILDER.length;
// Ueber die Galerie-Plaetze, nicht ueber alle: die Draufsicht wird
// uebersprungen (siehe nurStatus in BILDER).
const jetzt = GALERIE.findIndex((b) => b === BILDER[bild]);
const naechst = GALERIE[(Math.max(0, jetzt) + 1) % GALERIE.length];
bild = BILDER.indexOf(naechst);
const img = ROOT.getElementById("carimg");
if (!img) return;
const info = bildInfo(bild);
@@ -443,7 +458,7 @@ function bildWeiter() {
}
const sz = ROOT.getElementById("szene");
if (sz) sz.className = "szene";
[...ROOT.getElementById("dots").children].forEach((d, i) => d.classList.toggle("on", i === bild));
[...ROOT.getElementById("dots").children].forEach((d, i) => d.classList.toggle("on", GALERIE[i] === BILDER[bild]));
}
/* ---------------------------------------------------- Termine/Vorhersage
@@ -1974,7 +1989,7 @@ function vAudi() {
<div class="row"><dt>aktuell montiert:</dt><dd>${r.aktiv}räder</dd></div>
<div class="row"><dt>Laufleistung</dt><dd>${a.km != null ? de(a.km) + " km" : "unbekannt"}</dd></div>
<div class="row" style="border-bottom:0"><dt>Wechseltermin</dt>
<dd>${dedat(new Date(r.wechsel.datum))}${r.wechsel.vereinbart ? "" : ""}</dd></div>
<dd>${dedat(new Date(r.wechsel.datum))}</dd></div>
</dl></button>`;
}
@@ -2398,7 +2413,6 @@ function vService() {
return `<div class="termingruppe">
<div class="tg-kopf">
${kopf}
${t.vereinbart ? `<span class="tg-chip">Werkstatt am ${dedat(new Date(t.vereinbart))}</span>` : ""}
</div>
${herstellervorgabe ? `<div class="tg-vorgabe">Herstellervorgabe</div>` : ""}
<div class="tg-wert">${wert}</div>
@@ -2406,15 +2420,18 @@ function vService() {
</div>`; }).join("")}
</div>
<div class="tile"><span class="label">Termin vereinbaren</span>
${(() => { const ts = termine(), t = ts[Math.min(svAuswahl, ts.length - 1)];
const vorschlag = CAR.service.vereinbart[t.art] || (t.ziel ? t.ziel.toISOString().slice(0, 10) : "");
${(() => { const ts = terminAuswahl(), t = gewaehlterTermin();
/* Vorschlag ist das errechnete Faelligkeitsdatum, sonst leer. Der
eingetragene Termin wird NICHT gespeichert - siehe der Kommentar an
TerminVereinbaren() in der App: er dient allein dem Kalendereintrag
("was, wann, bei welcher Werkstatt") und hat keine weitere Wirkung. */
const vorschlag = t.ziel ? t.ziel.toISOString().slice(0, 10) : "";
return `
<div class="feld"><label for="svArt">Wofür</label>
<select id="svArt" style="width:132px" data-svsel>${ts.map((x, i) => `<option value="${i}" ${i === svAuswahl ? "selected" : ""}>${x.art}</option>`).join("")}</select></div>
<select id="svArt" style="width:190px" data-svsel>${ts.map((x, i) => `<option value="${i}" ${i === svAuswahl ? "selected" : ""}>${x.art}</option>`).join("")}</select></div>
<div class="feld" style="border-bottom:0"><label for="svDat">Werkstatttermin am</label>
<input id="svDat" type="date" style="width:132px" value="${vorschlag}" data-vereinbart="${esc(t.art)}"></div>
<button class="aktion" data-ics="service">In den Kalender übernehmen</button>
${CAR.service.vereinbart[t.art] ? `<button class="aktion" data-loeschen="${esc(t.art)}">Termin verwerfen</button>` : ""}`; })()}
<input id="svDat" type="date" style="width:150px" value="${vorschlag}"></div>
<button class="aktion" data-ics="service">In den Kalender übernehmen</button>`; })()}
</div>
<div class="tile">
<span class="label">Autohaus</span>
@@ -2528,6 +2545,23 @@ function vIdentBearbeiten() {
function monatePlus(datum, monate) { const d = dat(datum); d.setMonth(d.getMonth() + monate); return d; }
function letzterEintrag(stichwort) { return CAR.service.buch.find((e) => e.art.toLowerCase().includes(stichwort)) || null; }
/* Die Auswahl im Feld "Wofuer": die faelligen Termine plus "Andere".
"Andere" ist kein faelliger Termin, sondern ein freier Werkstattbesuch, der
zu keiner der drei geplanten Arten gehoert (Reifenwechsel, Garantiearbeit,
Nachbesserung). Er hat deshalb weder Ziel noch Kilometerangabe - nur ein
Datum, und taucht oben in der Terminliste nicht als Karte auf. */
function terminAuswahl() {
return [
...termine(),
{ art: "Andere", km: null, ziel: null },
];
}
function gewaehlterTermin() {
const auswahl = terminAuswahl();
return auswahl[Math.min(svAuswahl, auswahl.length - 1)];
}
function termine() {
const oel = CAR.oel;
const oelKm = oel.modus === "hersteller" ? oel.herstellerKm : Math.min(oel.km, oel.herstellerKm);
@@ -2538,7 +2572,7 @@ function termine() {
const kmDatum = kmZiel !== null ? prognose(kmZiel).datum : null;
const zuerst = (kmDatum && datZiel) ? (kmDatum < datZiel ? "km" : "zeit") : (kmDatum ? "km" : "zeit");
return { art, km: kmZiel, datum: datZiel, kmDatum, zuerst, basis,
ziel: zuerst === "km" ? kmDatum : datZiel, vereinbart: CAR.service.vereinbart[art] || null };
ziel: zuerst === "km" ? kmDatum : datZiel };
};
const oelTermin = bauen("Ölwechsel", letzterEintrag("öl"), oelKm, oelM);
// Genauere, eigenständige Ölwechsel-Prognose (siehe oelwechselPrognose())
@@ -2727,7 +2761,7 @@ function vBeitrag() {
return `
<div class="tile"><span class="label">Beitrag anpassen</span>
<div class="feld"><label for="vGueltigAb">Gültig ab</label>
<input id="vGueltigAb" type="date" style="width:132px" value="${v.gueltigAb}" data-vgueltigab></div>
<input id="vGueltigAb" type="date" style="width:150px" value="${v.gueltigAb}" data-vgueltigab></div>
<div class="feld"><label for="vSfHaft">Schadenfreiheitsklasse Haftpflicht</label>
<input id="vSfHaft" style="width:132px" value="${esc(v.teile[0]?.sf || "")}" data-vteil="0|sf"></div>
<div class="feld"><label for="vSfKasko">Schadenfreiheitsklasse Vollkasko</label>
@@ -2865,7 +2899,7 @@ function vSteuerBearbeiten() {
<option ${t.zeitraum === "halbjährlich" ? "selected" : ""}>halbjährlich</option>
</select></div>
<div class="feld"><label for="stFaellig">Nächste Fälligkeit</label>
<input id="stFaellig" type="date" style="width:132px" value="${t.faellig || ""}" data-steuerfaellig></div>
<input id="stFaellig" type="date" style="width:150px" value="${t.faellig || ""}" data-steuerfaellig></div>
<div class="feld" style="border-bottom:0"><label for="stLast">Lastschrift erteilt</label>
<label class="switch"><input id="stLast" type="checkbox" data-steuerlast ${t.lastschrift ? "checked" : ""}><span class="track"></span></label></div>
</div>`;
@@ -2979,7 +3013,7 @@ function vEinst() {
<div class="bildmenu-catcher" data-sdabbrechen></div>
<div class="sdpopup">
<div class="feld" style="border-bottom:0"><label for="sdPopupBis">Gültig bis</label>
<input id="sdPopupBis" type="date" style="width:132px" value="${CAR.smartdeal.laeuftAb || ""}"></div>
<input id="sdPopupBis" type="date" style="width:150px" value="${CAR.smartdeal.laeuftAb || ""}"></div>
<div class="seg" style="margin-top:14px">
<button type="button" data-sdabbrechen>Abbrechen</button>
<button type="button" class="on" data-sdbestaetigen>Bestätigen</button>
@@ -3621,7 +3655,7 @@ function vTrips() {
<p>Fahrten werden erkannt, sobald der Motor gestartet wird. Bis dahin lässt sich jede Fahrt von Hand erfassen.</p>
</div></div>
${vTripsFormular()}`;
let h = `<div class="tile">`;
let h = `<div class="tile liste">`;
jahre.forEach((j, ji) => {
const tj = neuesteZuerst(TRIPS.filter((t) => new Date(t.ts_start).getFullYear() === j), "ts_start");
h += `<button class="acc-h" aria-expanded="${ji === 0}" data-acc="y${j}"><span class="mark"></span>
@@ -4134,7 +4168,7 @@ function vFuel() {
<p>Ein Tankvorgang wird automatisch angelegt, sobald der Tankstand des Fahrzeugs steigt. Den Beleg kann man danach hochladen oder den Vorgang gleich hier von Hand erfassen.</p>
</div></div>
${vFuelFormular()}`;
let h = `<div class="tile">`;
let h = `<div class="tile liste">`;
jahre.forEach((j, ji) => {
const fj = neuesteZuerst(FILLS.filter((f) => new Date(f.ts).getFullYear() === j), "ts");
const ersparnisJahr = sum(fj, (f) => f.discount || 0);
@@ -5658,8 +5692,7 @@ function ereignisseVerdrahten() {
}
const dsExport = e.target.closest("[data-datensatzsichern]");
if (dsExport) { datensatzExportieren(dsExport.dataset.datensatzsichern); return; }
const del = e.target.closest("[data-loeschen]");
if (del) { delete CAR.service.vereinbart[del.dataset.loeschen]; profilSpeichern(); render(); return; }
/* Identität und Technik: Bearbeitungsansicht ein/aus, Positionen der
Technik- und Ausstattungsgruppen anlegen/entfernen. */
if (e.target.closest("[data-identbearbeiten]")) { identBearbeitenOffen = !identBearbeitenOffen; render(); return; }
@@ -5780,13 +5813,10 @@ function ereignisseVerdrahten() {
const k = e.target.closest("[data-ics]");
if (k) {
if (k.dataset.ics === "service") {
const ts = termine(), t = ts[Math.min(svAuswahl, ts.length - 1)];
const datum = $("#svDat").value;
CAR.service.vereinbart[t.art] = datum;
profilSpeichern();
ics(`${CONFIG.fahrzeugtitel}: ${t.art}`, datum, CAR.service.autohaus.name,
const t = gewaehlterTermin();
// Nur exportieren, nichts merken: der Termin lebt im Kalender.
ics(`${CONFIG.fahrzeugtitel}: ${t.art}`, $("#svDat").value, CAR.service.autohaus.name,
t.km !== null ? `Fällig bei ${de(t.km)} km · ${CAR.service.autohaus.adresse}` : CAR.service.autohaus.adresse);
render();
} else {
const datum = $("#rfDat").value;
CAR.reifen.wechsel.datum = datum;
@@ -5930,8 +5960,7 @@ function ereignisseVerdrahten() {
else CAR[ein] = e.target.value;
profilSpeichern(); return;
}
const vb = e.target.dataset.vereinbart;
if (vb) { CAR.service.vereinbart[vb] = e.target.value; profilSpeichern(); render(); return; }
const ah = e.target.dataset.ah;
if (ah) {
CAR.service.autohaus[ah] = e.target.value;
@@ -902,6 +902,41 @@ img.carfix.klein { aspect-ratio: auto; height: auto; }
.lvl2 { margin-left: 22px; }
.lvl2 .acc-h .k { font-size: 13px; }
/* Fahrten- und Tankliste: Abschnittsköpfe statt Einrückung.
Gemessen am 02.09.2026 im laufenden Panel: von der Innenkante der
Kachel bis zum Ortsnamen lagen 58px tote Breite - 22px Einrückung der
Monatsebene plus 40px linkes Polster der Zeile. Auf einem Telefon blieb
für "Eichstätt → Adelschlag" so wenig übrig, dass der Name umbrach
(gemeldet vom Eigentümer mit Bildschirmfoto).
Apples Richtlinien lösen Gruppierung nicht über Einrückung, sondern über
Abschnittsköpfe, die über die volle Breite laufen; die Zeilen darunter
stehen alle auf derselben Kante. Genau das ist hier umgesetzt - die
Monatszeile bleibt kleiner und grau und liest sich dadurch als Kopf,
ohne Platz zu kosten. Beschränkt auf .liste, weil das linke Polster von
.leaf anderswo (Batteriewerte, Servicebuch) gewollt ist. */
.liste .lvl2 { margin-left: 0; }
/* Der Monat ist ein Abschnittskopf, kein kleingeratener Eintrag.
Vorher unterschied er sich von der Fahrtzeile nur durch Groesse (13 gegen
16px) und Farbe - beim Lesen wirkte die Liste dadurch zweistufig statt
dreistufig, und genau danach hat der Eigentuemer gefragt (02.09.2026).
Versalien plus Sperrung sind die Form, die iOS fuer Abschnittskoepfe
benutzt und die in dieser Oberflaeche schon ueber dem Seitentitel steht
(.eyebrow).
11px und nicht 10: das ist die Untergrenze, die dieses Projekt aus den
Apple-Richtlinien uebernommen hat. In beiden Oberflaechen dieselben
Werte, damit der Kopf nicht je Geraet anders aussieht. */
.liste .lvl2 .acc-h .k {
font-size: 11px;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--fg3);
}
.liste .leaf { padding-left: 0; }
.tile-h {
display: block; width: 100%;
background: none; border: none;
@@ -1,7 +1,7 @@
{
"domain": "audi_dashboard",
"name": "Audi Dashboard",
"version": "2026.9.2.12",
"version": "2026.9.2.20",
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
"codeowners": ["@paul"],