863284e540
Gemeldet: Wartungsplan leer, Erstzulassung 01.03.2025, erwartet 01.03.2028 -
gezeigt wurden drei verschiedene Antworten (Uebersicht 01.08.2026, Mein Audi
"August 2026", Service "kein Eintrag im Wartungsplan").
Ursache war das Profilfeld hauptuntersuchung_faellig ("08/2026"), das die
Ableitung ueberstimmte, in keiner Oberflaeche ein Eingabefeld hatte und im
Backend kein Schema. Drei Bildschirme verarbeiteten denselben Wert
verschieden. Das Feld ist samt Lese- und Schreibweg entfernt; es gibt jetzt
zwei Quellen: Wartungsplan + 24 Monate, sonst Erstzulassung + 36 Monate.
Dabei mitbehoben:
* Die Service-Kachel verschluckte abgeleitete Termine, sobald kein
Wartungsplan-Eintrag dahinterstand.
* monatePlus() in der App verlor einen Tag ueber die Zeitumstellung (drei von
fuenf gemessenen Faellen) - betraf Oelwechsel- und Inspektionsprognose
genauso. Das Panel rechnete dort seit jeher richtig.
* Die App verlangte fuer jeden Wartungsplan-Eintrag eine Kilometerangabe. Eine
Hauptuntersuchung ist rein datumsbasiert; ohne km waere der erste HU-Eintrag
ignoriert worden. Ausserdem nahm das Panel den ersten Treffer der Liste, die
App den juengsten - beide nehmen jetzt den juengsten.
* Monatsgenauigkeit ("03/2025") bleibt auf beiden Seiten erhalten.
Neu: paritaet_hu.test.ts vergleicht die App gegen die aus der Panel-Quelle
herausgeschnittenen Originalfunktionen, 15 Faelle. Gesamt 292 App-Tests, 73
Python-Tests mit 231 Untertests, 0 Tracebacks. Live an der Testinstanz auf
allen drei gemeldeten Bildschirmen geprueft.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
274 lines
11 KiB
TypeScript
274 lines
11 KiB
TypeScript
/**
|
||
* Zahlen, Geld, Datum und Dauer im deutschen Format. Aus dem alten Panel
|
||
* übernommen (`de()`/`eur()`), damit App und Panel identisch formatieren.
|
||
*/
|
||
|
||
export function de(wert: number | null | undefined, nachkomma = 0): string {
|
||
return Number(wert ?? 0).toLocaleString("de-DE", {
|
||
minimumFractionDigits: nachkomma,
|
||
maximumFractionDigits: nachkomma,
|
||
})
|
||
}
|
||
|
||
export function eur(wert: number | null | undefined): string {
|
||
return de(wert, 2)
|
||
}
|
||
|
||
/* Fehlende Werte zeigt das Panel durchgängig als Halbgeviertstrich „–", nicht
|
||
als Geviertstrich „—" (30 Fundstellen gegen eine). Hier stand app-weit der
|
||
längere Strich — sichtbar breiter, sobald beide Anwendungen nebeneinander
|
||
liegen. */
|
||
|
||
/** Wie de(), aber „–" statt „0" wenn nichts bekannt ist. */
|
||
/** Eine gefahrene Strecke in der Staffelung, die der Eigentümer am 01.09.2026
|
||
* festgelegt hat:
|
||
*
|
||
* unter 1 km -> Meter, ganzzahlig ("400 m", "999 m")
|
||
* bis 99,9 km -> eine Nachkommastelle ("1,0 km", "6,9 km")
|
||
* ab 100 km -> ganze Kilometer ("104 km")
|
||
*
|
||
* Seit der GNSS-Verfeinerung messen wir auf etwa zehn Meter genau; ohne
|
||
* Nachkommastelle wurde davon nichts sichtbar (aus 6,896 km wurde "7 km").
|
||
* Umgekehrt wäre eine Nachkommastelle bei dreistelligen Summen nur Unruhe.
|
||
*
|
||
* Gestaffelt wird nach dem GERUNDETEN Wert: 0,9996 km sind gerundet 1000 m
|
||
* und gehören damit schon in die km-Stufe, sonst stünde dort "1.000 m".
|
||
*
|
||
* Wortgleich mit streckeTeile()/streckeText() im Panel. */
|
||
export function streckeTeile(km: number | null | undefined): { wert: string; einheit: string } | null {
|
||
const n = Number(km)
|
||
if (km === null || km === undefined || !Number.isFinite(n)) return null
|
||
const meter = Math.round(n * 1000)
|
||
if (Math.abs(meter) < 1000) return { wert: de(meter), einheit: "m" }
|
||
const fein = Math.round(n * 10) / 10
|
||
if (Math.abs(fein) < 100) return { wert: de(fein, 1), einheit: "km" }
|
||
return { wert: de(Math.round(n)), einheit: "km" }
|
||
}
|
||
|
||
export function streckeText(km: number | null | undefined, ersatz = "–"): string {
|
||
const t = streckeTeile(km)
|
||
return t ? `${t.wert} ${t.einheit}` : ersatz
|
||
}
|
||
|
||
export function deOderStrich(wert: number | null | undefined, nachkomma = 0): string {
|
||
return wert === null || wert === undefined ? "–" : de(wert, nachkomma)
|
||
}
|
||
|
||
/** Summe über eine Liste. */
|
||
export function summe<T>(liste: readonly T[], auswahl: (eintrag: T) => number): number {
|
||
return liste.reduce((zwischenstand, eintrag) => zwischenstand + (auswahl(eintrag) || 0), 0)
|
||
}
|
||
|
||
/**
|
||
* Durchschnittspreis je Liter — immer mengengewichtet (Summe Kosten geteilt
|
||
* durch Summe Liter), nie als Mittel der Einzelpreise. Eine kleine Tankung zu
|
||
* hohem Preis darf den Schnitt nicht so stark heben wie eine große.
|
||
*/
|
||
export function schnittpreis(
|
||
liste: readonly { liters?: number | null; fuel_total_eur?: number | null }[],
|
||
): number {
|
||
const liter = summe(liste, (t) => t.liters ?? 0)
|
||
if (liter <= 0) return 0
|
||
return summe(liste, (t) => t.fuel_total_eur ?? 0) / liter
|
||
}
|
||
|
||
const DATUM_KURZ = new Intl.DateTimeFormat("de-DE", {
|
||
day: "2-digit",
|
||
month: "2-digit",
|
||
year: "numeric",
|
||
})
|
||
|
||
const DATUM_LANG = new Intl.DateTimeFormat("de-DE", {
|
||
day: "numeric",
|
||
month: "long",
|
||
year: "numeric",
|
||
})
|
||
|
||
const UHRZEIT = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit" })
|
||
|
||
const MONAT_JAHR = new Intl.DateTimeFormat("de-DE", { month: "long", year: "numeric" })
|
||
|
||
/**
|
||
* Datumsangaben aus dem Bestand kommen in DREI Formen - deshalb reicht
|
||
* `new Date(wert)` hier nicht.
|
||
*
|
||
* WAS AM 04.09.2026 GEMESSEN WURDE
|
||
* --------------------------------
|
||
* `new Date()` liest einen nicht-ISO-Text nach amerikanischer Lesart, also
|
||
* Monat vor Tag. Fuer deutsche Eingaben heisst das:
|
||
*
|
||
* "01.03.2025" (1. Maerz) -> 3. Januar 2025 - Tag und Monat vertauscht
|
||
* "12.11.2024" -> 11. Dezember - dito
|
||
* "25.03.2025" -> ungueltig - Wert verschwand als "-"
|
||
* "08/2026" (Hauptunters.) -> ungueltig - dito
|
||
*
|
||
* Tage bis 12 wurden also STILL vertauscht, ab Tag 13 fiel der Wert ganz weg.
|
||
* Sichtbar war es an der Erstzulassung: "Mein Audi" zeigte den Rohwert
|
||
* richtig, "Identitaet und Technik" formatierte ihn und drehte ihn dabei um -
|
||
* derselbe Wert, zwei Ergebnisse. Und die Hauptuntersuchung stand als "-" da,
|
||
* obwohl "08/2026" gespeichert war.
|
||
*
|
||
* WARUM EIGENE MUSTER STATT EINER BIBLIOTHEK
|
||
* ------------------------------------------
|
||
* Es sind genau drei Formen, alle aus diesem Projekt: ISO vom Backend,
|
||
* "TT.MM.JJJJ" aus den Eingabefeldern, "MM/JJJJ" fuer die Hauptuntersuchung
|
||
* (so verlangt es das Einrichtungsfeld, siehe Panel `einErstzulassung`).
|
||
* Erkannt wird nur, was hier wirklich vorkommt - alles andere bleibt `null`
|
||
* und damit "-". Lieber kein Datum als ein erfundenes.
|
||
*
|
||
* MONATSGENAU IST EINE EIGENE GENAUIGKEIT
|
||
* ---------------------------------------
|
||
* "08/2026" nennt keinen Tag. Daraus den 1. August zu machen und
|
||
* "01.08.2026" anzuzeigen waere eine Erfindung - deshalb traegt das Ergebnis
|
||
* seine Genauigkeit mit sich, und `datum()` zeigt dann "August 2026".
|
||
*/
|
||
type Genauigkeit = "tag" | "monat"
|
||
interface Zeitpunkt {
|
||
zeit: Date
|
||
genau: Genauigkeit
|
||
}
|
||
|
||
/* ISO vom Backend, mit oder ohne Uhrzeit - das kann `new Date` selbst, und
|
||
zwar nach Norm statt nach Gutduenken. */
|
||
const ISO = /^\d{4}-\d{2}-\d{2}([T ]|$)/
|
||
/* "1.3.2025" wie "01.03.2025". */
|
||
const DE_TAG = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/
|
||
/* "08/2026" und "08.2026" - Monat und Jahr, ohne Tag. */
|
||
const DE_MONAT = /^(\d{1,2})[./](\d{4})$/
|
||
/* "2026-08" - dieselbe Genauigkeit, in ISO-Schreibweise. */
|
||
const ISO_MONAT = /^(\d{4})-(\d{1,2})$/
|
||
|
||
/**
|
||
* Baut das Datum und weist die Rollover-Falle ab: `new Date(2025, 1, 31)` ist
|
||
* in JavaScript der 3. Maerz, nicht der 31. Februar. Ein unmoegliches Datum
|
||
* soll "-" ergeben, nicht klammheimlich ein anderes.
|
||
*/
|
||
function bauen(jahr: number, monat: number, tag: number): Date | null {
|
||
if (monat < 1 || monat > 12 || tag < 1 || tag > 31) return null
|
||
const d = new Date(jahr, monat - 1, tag)
|
||
if (d.getFullYear() !== jahr || d.getMonth() !== monat - 1 || d.getDate() !== tag) return null
|
||
return d
|
||
}
|
||
|
||
/** Wortgleich mit datumAusText() im Panel. Exportiert, weil die Ableitung
|
||
der Hauptuntersuchung dieselbe Genauigkeit braucht wie die Anzeige: aus
|
||
einer Erstzulassung "03/2025" darf kein Termin mit Tag entstehen. */
|
||
export function alsZeitpunkt(wert: string | number | Date | null | undefined): Zeitpunkt | null {
|
||
if (wert === null || wert === undefined || wert === "") return null
|
||
if (wert instanceof Date) return Number.isNaN(wert.getTime()) ? null : { zeit: wert, genau: "tag" }
|
||
if (typeof wert === "number") {
|
||
const d = new Date(wert)
|
||
return Number.isNaN(d.getTime()) ? null : { zeit: d, genau: "tag" }
|
||
}
|
||
|
||
const text = wert.trim()
|
||
if (!text) return null
|
||
|
||
if (ISO.test(text)) {
|
||
const d = new Date(text)
|
||
return Number.isNaN(d.getTime()) ? null : { zeit: d, genau: "tag" }
|
||
}
|
||
|
||
const tag = DE_TAG.exec(text)
|
||
if (tag) {
|
||
const d = bauen(Number(tag[3]), Number(tag[2]), Number(tag[1]))
|
||
return d ? { zeit: d, genau: "tag" } : null
|
||
}
|
||
|
||
const monat = DE_MONAT.exec(text)
|
||
if (monat) {
|
||
const d = bauen(Number(monat[2]), Number(monat[1]), 1)
|
||
return d ? { zeit: d, genau: "monat" } : null
|
||
}
|
||
|
||
const isoMonat = ISO_MONAT.exec(text)
|
||
if (isoMonat) {
|
||
const d = bauen(Number(isoMonat[1]), Number(isoMonat[2]), 1)
|
||
return d ? { zeit: d, genau: "monat" } : null
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
function alsDatum(wert: string | number | Date | null | undefined): Date | null {
|
||
return alsZeitpunkt(wert)?.zeit ?? null
|
||
}
|
||
|
||
export function datum(wert: string | number | Date | null | undefined): string {
|
||
const z = alsZeitpunkt(wert)
|
||
if (!z) return "–"
|
||
return z.genau === "monat" ? MONAT_JAHR.format(z.zeit) : DATUM_KURZ.format(z.zeit)
|
||
}
|
||
|
||
export function datumLang(wert: string | number | Date | null | undefined): string {
|
||
const z = alsZeitpunkt(wert)
|
||
if (!z) return "–"
|
||
return z.genau === "monat" ? MONAT_JAHR.format(z.zeit) : DATUM_LANG.format(z.zeit)
|
||
}
|
||
|
||
export function uhrzeit(wert: string | number | Date | null | undefined): string {
|
||
const z = alsZeitpunkt(wert)
|
||
// Eine monatsgenaue Angabe hat keine Uhrzeit - 00:00 waere erfunden.
|
||
return z && z.genau === "tag" ? UHRZEIT.format(z.zeit) : "–"
|
||
}
|
||
|
||
export function datumZeit(wert: string | number | Date | null | undefined): string {
|
||
const z = alsZeitpunkt(wert)
|
||
if (!z) return "–"
|
||
if (z.genau === "monat") return MONAT_JAHR.format(z.zeit)
|
||
return `${DATUM_KURZ.format(z.zeit)}, ${UHRZEIT.format(z.zeit)} Uhr`
|
||
}
|
||
|
||
export function monatJahr(wert: string | number | Date | null | undefined): string {
|
||
const z = alsZeitpunkt(wert)
|
||
return z ? MONAT_JAHR.format(z.zeit) : "–"
|
||
}
|
||
|
||
/** Dauer in Sekunden als „2 h 14 min" bzw. „14 min". */
|
||
export function dauer(sekunden: number | null | undefined): string {
|
||
if (!sekunden || sekunden <= 0) return "–"
|
||
const minutenGesamt = Math.round(sekunden / 60)
|
||
const stunden = Math.floor(minutenGesamt / 60)
|
||
const minuten = minutenGesamt % 60
|
||
// "Min." statt "min": deutsche Abkuerzung, wie sie die Oberflaeche sonst
|
||
// auch verwendet ("Geparkt seit 2 Tg. 16 Std. 12 Min.").
|
||
if (stunden === 0) return `${minuten} Min.`
|
||
return `${stunden} h ${String(minuten).padStart(2, "0")} Min.`
|
||
}
|
||
|
||
/**
|
||
* Alter des letzten Datenstands als „vor 3 Std." — wortgleich mit
|
||
* `standAlterText()` im Panel, das diesen Text in der Kopfzeile der Übersicht
|
||
* neben dem Aktualisieren-Symbol zeigt.
|
||
*/
|
||
export function standAlter(wert: string | number | Date | null | undefined): string {
|
||
const d = alsDatum(wert)
|
||
if (!d) return "unbekannt"
|
||
const sekunden = Math.max(0, Math.floor((Date.now() - d.getTime()) / 1000))
|
||
if (sekunden < 60) return `vor ${sekunden} Sek.`
|
||
const minuten = Math.floor(sekunden / 60)
|
||
if (minuten < 60) return `vor ${minuten} Min.`
|
||
const stunden = Math.floor(minuten / 60)
|
||
if (stunden < 24) return `vor ${stunden} Std.`
|
||
return `vor ${Math.floor(stunden / 24)} Tage`
|
||
}
|
||
|
||
/** ISO-Tagesdatum (YYYY-MM-DD) für Vergleiche und Eingabefelder. */
|
||
export function isoTag(wert: string | number | Date | null | undefined = new Date()): string {
|
||
const d = alsDatum(wert)
|
||
if (!d) return ""
|
||
const versatz = d.getTimezoneOffset() * 60_000
|
||
return new Date(d.getTime() - versatz).toISOString().slice(0, 10)
|
||
}
|
||
|
||
/**
|
||
* Datum und Uhrzeit mit „ · " getrennt — so setzt das Panel den Zeitstempel in
|
||
* der Batterie-Messwertliste (`dedat(...) + " · " + toLocaleTimeString(...)`).
|
||
* `datumZeit()` daneben bleibt die Komma-Schreibweise für die Stellen, die sie
|
||
* schon benutzen.
|
||
*/
|
||
export function datumUndZeit(wert: string | number | Date | null | undefined): string {
|
||
const d = alsDatum(wert)
|
||
return d ? `${DATUM_KURZ.format(d)} · ${UHRZEIT.format(d)} Uhr` : "–"
|
||
}
|