Parity-Regel: Uebersicht-Service-Block in companion-app portiert

Nutzer stellte klar: Panel und companion-app sollen immer denselben
funktionalen Stand haben, auch wenn eine Anfrage nur das Panel nennt -
das ist reiner Testkomfort (Docker-Instanz laesst sich am schnellsten
ansehen), keine Scope-Entscheidung. Regel als verbindlicher Absatz in
AGENTS.md verankert, direkt unter der bestehenden Maintenance-Regel.

Erste Anwendung im selben Zug: die eben im Panel gebaute
"Naechster Service"-Kachel nach companion-app/ portiert.

- daten/service.ts: neues naechsterService() als Gegenstueck zu
  naechsterTermin() im Panel - waehlt ueber Oelwechsel-/Inspektions-
  Prognose und die von Hand gepflegte Hauptuntersuchung hinweg den
  zeitlich naechsten Termin. bisText() liefert denselben Artikel wie
  ART_BIS im Panel ("bis zum Oelwechsel" / "bis zur Inspektion").
- screens/Uebersicht.tsx: die bisherigen Kacheln "Reichweite"/
  "Kilometerstand" nebeneinander plus eine separate "Service"-Kachel
  mit Werteliste weichen einer Reichweiten-Kachel plus einer
  Service-Kachel mit .dm-serviceblock (Knopf, nur der obere Teil) und
  .dm-servicezeile (reine Anzeige) darunter - dieselbe Control/Content-
  Trennung wie im Panel.
- stile/screens.css: .dm-serviceblock/.dm-servicezeile ergaenzt.

Tests: 4 neue in service.test.ts (naechsterService waehlt das frueher
faellige Datum, nimmt die Hauptuntersuchung auf, liefert nichts ohne
Servicebuch/HU, bisText-Artikel je Art), 1 neuer in screens.test.tsx,
der mit einem vi.fn() als geheZu wirklich belegt, dass ein Klick auf
den Serviceblock navigiert und ein Klick auf die Kilometerstand-Zeile
es nicht tut - dafuer bekam zeige() in screens.test.tsx erst einen
injizierbaren geheZu-Parameter (vorher hart auf () => {} verdrahtet).

npm run typecheck sauber, npm run test 100/100 (von 95), npm run build
erfolgreich.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-17 20:44:31 +02:00
co-authored by Claude Opus 5
parent 600c92fd0f
commit 2848b5286f
6 changed files with 256 additions and 62 deletions
+38 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"
import { inspektionPrognose, letzterOelwechsel, oelwechselPrognose } from "./service"
import { bisText, inspektionPrognose, letzterOelwechsel, naechsterService, oelwechselPrognose } from "./service"
import type { Fahrzeug } from "./profilAdapter"
function fahrzeug(odo: number, modus = "hersteller"): Fahrzeug {
@@ -112,3 +112,40 @@ describe("inspektionPrognose", () => {
expect(inspektionPrognose(fahrzeug(46000), [], jetzt)).toBeNull()
})
})
describe("naechsterService", () => {
const jetzt = new Date("2026-08-11T12:00:00")
it("wählt über Ölwechsel und Inspektion hinweg das zeitlich frühere Datum", () => {
// Beide unabhängig mit derselben Formel berechnet, statt ein erwartetes
// Datum von Hand vorzurechnen - so bleibt der Test robust gegen spätere
// Änderungen an den Intervallen, prüft aber trotzdem die Auswahllogik.
const oel = oelwechselPrognose(fahrzeug(46000), BUCH, jetzt)
const insp = inspektionPrognose(fahrzeug(46000), BUCH, jetzt)
const erwarteteArt = oel!.datum <= insp!.datum ? "Ölwechsel" : "Inspektion"
const s = naechsterService(fahrzeug(46000), BUCH, jetzt)
expect(s).not.toBeNull()
expect(s!.art).toBe(erwarteteArt)
expect(s!.datum).toBe(erwarteteArt === "Ölwechsel" ? oel!.datum : insp!.datum)
})
it("nimmt die Hauptuntersuchung auf, obwohl sie keine eigene Prognose hat", () => {
const f = { ...fahrzeug(46000), hu: "2026-09-01" }
const s = naechsterService(f, [], jetzt)
expect(s).not.toBeNull()
expect(s!.art).toBe("Hauptuntersuchung")
expect(s!.restKm).toBeNull()
expect(s!.datum).toBe("2026-09-01")
})
it("liefert nichts ohne Servicebuch und ohne gepflegte Hauptuntersuchung", () => {
expect(naechsterService(fahrzeug(46000), [], jetzt)).toBeNull()
})
it("bisText nennt den richtigen Artikel je Art", () => {
expect(bisText("Ölwechsel")).toBe("bis zum Ölwechsel")
expect(bisText("Inspektion")).toBe("bis zur Inspektion")
expect(bisText("Hauptuntersuchung")).toBe("bis zur Hauptuntersuchung")
})
})
+63
View File
@@ -120,3 +120,66 @@ export function inspektionPrognose(
): Oelprognose | null {
return servicePrognose(fahrzeug, letzterEintrag(buch, /inspektion/i), 30000, 24, jetzt)
}
/* ------------------------------------------------------ Nächster Service */
export type Serviceart = "Ölwechsel" | "Inspektion" | "Hauptuntersuchung"
export interface NaechsterService {
art: Serviceart
/** Restkilometer bis zur Fälligkeit. null bei der Hauptuntersuchung — die
hat kein Kilometerziel, nur einen Termin. */
restKm: number | null
/** ISO-Tagesdatum der voraussichtlichen Fälligkeit. */
datum: string
}
/* "der Ölwechsel", aber "die Inspektion"/"die Hauptuntersuchung" - ohne diese
Zuordnung stünde auf der Übersicht "bis zum Inspektion". Gleiche Tabelle wie
ART_BIS im Panel (audi-dashboard-app.js). */
const BIS_TEXT: Record<Serviceart, string> = {
"Ölwechsel": "bis zum Ölwechsel",
"Inspektion": "bis zur Inspektion",
"Hauptuntersuchung": "bis zur Hauptuntersuchung",
}
export function bisText(art: Serviceart): string {
return BIS_TEXT[art]
}
/**
* Der zeitlich nächste anstehende Service über alle drei Arten hinweg —
* Gegenstück zu `naechsterTermin()` im Panel.
*
* Ölwechsel und Inspektion kommen aus der eigenen Prognose (Servicebuch plus
* tatsächliche Fahrleistung), die Hauptuntersuchung aus dem im Profil
* gepflegten Termin: sie hat kein Kilometerziel, das Fahrzeug meldet sie nicht,
* und hochrechnen lässt sie sich auch nicht.
*/
export function naechsterService(
fahrzeug: Fahrzeug,
buch: readonly Buchhaltung[],
jetzt = new Date(),
): NaechsterService | null {
const kandidaten: NaechsterService[] = []
const oel = oelwechselPrognose(fahrzeug, buch, jetzt)
if (oel) kandidaten.push({ art: "Ölwechsel", restKm: oel.restKm, datum: oel.datum })
const insp = inspektionPrognose(fahrzeug, buch, jetzt)
if (insp) kandidaten.push({ art: "Inspektion", restKm: insp.restKm, datum: insp.datum })
// fahrzeug.hu wird von Hand gepflegt und kann leer oder unlesbar sein.
const hu = new Date(fahrzeug.hu)
if (fahrzeug.hu && !Number.isNaN(hu.getTime())) {
kandidaten.push({
art: "Hauptuntersuchung",
restKm: null,
datum: hu.toISOString().slice(0, 10),
})
}
if (kandidaten.length === 0) return null
// ISO-Tagesdaten, deshalb reicht der Zeichenkettenvergleich.
return kandidaten.sort((a, b) => a.datum.localeCompare(b.datum))[0] ?? null
}