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
+55 -52
View File
@@ -11,10 +11,12 @@ import { useState } from "react"
import { Fig, ProgressBar, StatusRow, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { bisText, naechsterService } from "../daten/service"
import { datum, datumZeit, dauer, de, deOderStrich, eur } from "../format"
import type { SeitenName } from "../navigation"
import { Leerzustand, NaviKachel, Wertzeile, Werteliste } from "./bausteine"
import { Leerzustand, NaviKachel } from "./bausteine"
import { Bild } from "./Bild"
import type { ServicebuchEintrag } from "./Service"
import { Typenschild, schildErkennen } from "./Typenschild"
export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
@@ -40,6 +42,9 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
.join(" ")
.trim()
const buch = (fahrzeug.service["buch"] ?? []) as ServicebuchEintrag[]
const service = naechsterService(fahrzeug, buch)
const letzteFahrt = fahrten
.slice()
.sort((a, b) => new Date(b.ts_start).getTime() - new Date(a.ts_start).getTime())[0]
@@ -103,62 +108,60 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
onClick={() => geheZu("sicherheit")}
/>
<div className="dm-raster">
<Tile>
<span className="ads-eyebrow">Reichweite</span>
<Fig value={deOderStrich(fahrzeug.reichweite)} unit="km" size={40} />
<ProgressBar percent={Math.max(0, Math.min(100, fahrzeug.tankPct))} />
<div className="dm-tanktext">
<span>{de(fahrzeug.tankPct)} % im Tank</span>
<span>
rund {de((einstellungen.tankvolumen * fahrzeug.tankPct) / 100, 1)} von{" "}
{de(einstellungen.tankvolumen)} l
</span>
</div>
</Tile>
<Tile>
<span className="ads-eyebrow">Reichweite</span>
<Fig value={deOderStrich(fahrzeug.reichweite)} unit="km" size={40} />
<ProgressBar percent={Math.max(0, Math.min(100, fahrzeug.tankPct))} />
<div className="dm-tanktext">
<span>{de(fahrzeug.tankPct)} % im Tank</span>
<span>
rund {de((einstellungen.tankvolumen * fahrzeug.tankPct) / 100, 1)} von{" "}
{de(einstellungen.tankvolumen)} l
</span>
</div>
</Tile>
<Tile>
<span className="ads-eyebrow">Kilometerstand</span>
<Fig
value={fahrzeug.odoBekannt ? de(fahrzeug.odo) : "—"}
unit="km"
size={40}
/>
</Tile>
</div>
{/* Kilometerstand und Service standen bis 2026-08-17 als zwei gleich
große Kacheln nebeneinander — gleiche Zahlengröße, gleiche Farbe,
obwohl die beiden Zahlen Grundverschiedenes meinen: ein gemessener
Ist-Wert gegen einen täglich schrumpfenden Countdown.
{/* Service stand bis 2026-08-17 als Werteliste INNERHALB der
Kilometerstand-Kachel - zwei Termine unter einer Überschrift, die
nichts mit ihnen zu tun hat. Apple HIG "Layout" verlangt, verwandte
Dinge zu gruppieren und Unverwandtes zu trennen; außerdem führte von
hier kein Weg zur Service-Seite, obwohl es sie gibt. Jetzt eigene,
antippbare Kachel - dieselbe Auflösung wie im Panel (vHome). */}
<Tile variant="button" chevron onClick={() => geheZu("service")}>
<span className="ads-eyebrow">Service</span>
<Werteliste
kinder={
Jetzt eine führende Kennzahl, darunter der Kilometerstand als ruhige
Wertezeile — Audi-CI (Hierarchie über Größe und Farbe) plus Apple HIGs
"differentiate controls from content": nur der obere Teil ist der
Knopf zur Service-Seite, die Kilometerstand-Zeile bleibt reine
Anzeige. Deckungsgleich mit vHome() im Panel. */}
<Tile>
<button
type="button"
className="dm-serviceblock"
onClick={() => geheZu("service")}
>
<span className="ads-eyebrow">Nächster Service:</span>
<span className="dm-serviceblock__chevron" aria-hidden="true">
</span>
{service ? (
<>
<Wertzeile
label="Nächster Ölwechsel"
wert={fahrzeug.oelwechselFaelligTs ? datum(fahrzeug.oelwechselFaelligTs) : "—"}
zusatz={
fahrzeug.oelwechselFaelligKm != null
? `in ${de(Math.abs(fahrzeug.oelwechselFaelligKm))} km`
: undefined
}
/>
<Wertzeile
label="Nächste Inspektion"
wert={fahrzeug.inspektionFaelligTs ? datum(fahrzeug.inspektionFaelligTs) : "—"}
zusatz={
fahrzeug.inspektionFaelligKm != null
? `in ${de(Math.abs(fahrzeug.inspektionFaelligKm))} km`
: undefined
}
<Fig
value={service.restKm !== null ? de(service.restKm) : datum(service.datum)}
{...(service.restKm !== null ? { unit: "km" } : {})}
size={service.restKm !== null ? 40 : 30}
/>
<span className="dm-fussnote">
{service.restKm !== null
? `${bisText(service.art)} · vsl. ${datum(service.datum)}`
: bisText(service.art)}
</span>
</>
}
/>
) : (
<span className="dm-fussnote">kein Eintrag im Servicebuch</span>
)}
</button>
<div className="dm-servicezeile">
<span className="dm-servicezeile__k">Kilometerstand</span>
<Fig value={fahrzeug.odoBekannt ? de(fahrzeug.odo) : "—"} unit="km" size={20} />
</div>
</Tile>
<div className="dm-raster">
+21 -4
View File
@@ -5,7 +5,7 @@
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import { beforeEach, describe, expect, it } from "vitest"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { DatenAnbieter } from "../daten/DatenKontext"
import { Shell } from "../Shell"
@@ -17,19 +17,19 @@ import { bildschirmbreiteSetzen } from "../tests/aufbau"
const ALLE_SEITEN = Object.keys(TITEL) as SeitenName[]
function zeige(name: SeitenName, id?: string) {
function zeige(name: SeitenName, id?: string, geheZu: (name: SeitenName, id?: string) => void = () => {}) {
const api = beispielApi()
return render(
<div className="ads-root" data-theme="nacht">
<DatenAnbieter api={api}>
<Shell
route={id === undefined ? { name } : { name, id }}
geheZu={() => {}}
geheZu={geheZu}
tabBeschriftung
>
<SeiteFuer
route={id === undefined ? { name } : { name, id }}
geheZu={() => {}}
geheZu={geheZu}
tabBeschriftung
setzeTabBeschriftung={() => {}}
beiAbmeldung={() => {}}
@@ -82,6 +82,23 @@ describe("Inhalte kommen wirklich aus den Daten", () => {
expect(screen.getByText("385")).toBeTruthy()
})
it("Übersicht: der Serviceblock navigiert, die Kilometerstand-Zeile nicht", async () => {
// Reproduziert die eigentliche HIG-Vorgabe hinter dem Layout
// ("differentiate controls from content"), nicht nur das Aussehen:
// nur der obere Teil der Kachel ist ein Knopf.
const geheZu = vi.fn()
const { container } = zeige("home", undefined, geheZu)
await waitFor(() => expect(screen.getByText("Nächster Service:")).toBeTruthy())
fireEvent.click(screen.getByText("Nächster Service:"))
expect(geheZu).toHaveBeenCalledWith("service")
geheZu.mockClear()
const zeile = container.querySelector(".dm-servicezeile")
if (zeile) fireEvent.click(zeile)
expect(geheZu).not.toHaveBeenCalled()
})
it("zeigt einen unbekannten Prüfpunkt als unbekannt, nicht als sicher", async () => {
const { container } = zeige("sicherheit")
await waitFor(() => expect(container.querySelector(".dm-pruefliste")).not.toBeNull())