/** * 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(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` : "–" }