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
+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;