44dcb3c9d9
Einstellungen.tsx fuehrt fuer die Erstzulassung einen eigenen Entwurf neben `entwurf`. Zwei Stellen kannten ihn nicht: speichern() stieg in der ersten Zeile aus, und die Speicherleiste haengt allein an `entwurf` - wer nur die Erstzulassung aenderte, bekam also gar keinen Speichern-Knopf zu sehen und verlor den Wert beim Verlassen der Seite. Damit blieb auch die Hauptuntersuchung leer, die daraus abgeleitet wird. Das Panel speichert an dieser Stelle sofort und war nie betroffen. Dazu: * Platzhalter beider Felder war MM/JJJJ, obwohl ein voller Tag gemeint ist - jetzt TT.MM.JJJJ. Die Monatsform bleibt lesbar, damit gespeicherte Werte wie "08/2026" gueltig bleiben. * Das Tagesmuster nimmt jetzt Punkt oder Bindestrich, in beiden Codebasen wortgleich. "01-03-2025" ergab vorher still nichts. Unmoegliche Daten werden weiterhin abgewiesen, die ISO-Form nicht verwechselt. Geprueft am laufenden System ueber die ganze Kette: eintragen, speichern, Profildatei, dann App-Service, Panel-Service und Panel-Mein-Audi mit uebereinstimmend 15.06.2027. 299 Tests gruen, 0 Tracebacks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
277 lines
11 KiB
TypeScript
277 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". */
|
||
// Bindestrich ist mitgemeint: der Eigentuemer hat am 05.09.2026 "mit Punkt
|
||
// oder Strich" eingetragen. Eine still verworfene Eingabe ist der schlechtere
|
||
// Ausgang - der Platzhalter nennt trotzdem TT.MM.JJJJ als die gemeinte Form.
|
||
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` : "–"
|
||
}
|