Files
audi-app/companion-app/src/format.ts
T
tobias 863284e540 Hauptuntersuchung: Ableitung aus Erstzulassung und Wartungsplan, drittes Feld entfernt
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>
2026-09-05 01:56:11 +02:00

274 lines
11 KiB
TypeScript
Raw 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". */
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` : ""
}