Files
tobias 44dcb3c9d9 Erstzulassung war in der App nicht speicherbar
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>
2026-09-05 10:31:53 +02:00

277 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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` : ""
}