diff --git a/AGENTS.md b/AGENTS.md index 9762096..407da29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,18 @@ the linked documents — this file points, it does not duplicate. > adjust the status table, bump the "Last updated" date). This file must always be written in > **English**, even though the rest of the project is German. +> **Parity rule (binding, stated by the owner 2026-08-17):** the HA panel +> (`homeassistant/www/audi-dashboard-app.js`) and the companion app (`companion-app/`) must +> **always be at the same functional state**. When a request names only the panel, that is *not* a +> scoping decision — the owner simply tests there because the Docker instance makes it quick to see. +> Port every user-visible change to both in the **same session**, and say so in the report. The two +> are independent implementations (vanilla JS + shadow DOM vs. React + `@audi-dash/ui`), so a port +> is a re-implementation, not a copy: keep the *behaviour and wording* identical, let each side use +> its own idiom, and skip only what genuinely cannot apply (documented per case — e.g. panel-only +> CSS bugs, or HA-panel-only concepts like the entity Setup menu). If a port is impossible or +> unreasonably large, say so explicitly instead of leaving it silently undone. This rule exists +> because the two had already drifted six days apart once (see section A). + **How this file is loaded:** Claude Code does not read `AGENTS.md` natively; the root `CLAUDE.md` imports it via `@AGENTS.md` (official recommended pattern). Other agents (Codex, Cursor, Copilot) read `AGENTS.md` directly. Edit content here, not in `CLAUDE.md`. @@ -1459,11 +1471,29 @@ wraps the web app for iPhone; a PWA home-screen install is the accepted intermed jede Odometer-Aktualisierung verschiebt sie), das Tagesdatum also mehr Genauigkeit verspricht, als der Wert hat. Live geprüft bei 375px und 1280px: Block und Wertezeile fluchten mit der Kachelkante (16→359 bzw. 564→1236), Chevron bündig rechts, Tippfläche 88px hoch, keine neuen - Konsolenfehler. Deployed als Version `1787006000`, synchron in `installationspaket/`. - Entwurf und Begründung als Artefakt: „Zwei Kacheln, drei Antworten“. - **Noch offen:** die Änderung existiert nur im Panel, nicht in `companion-app/` — dort steht - die Übersicht weiter mit dem alten Aufbau (siehe den Drift-Eintrag weiter oben; die beiden - Codebasen laufen sonst wieder auseinander). + Konsolenfehler. Deployed als Version `1787007000` (zwischenzeitlich auf Nutzerwunsch von + `.tile.flat` auf eine normale `.tile` mit Hintergrund umgestellt — `.serviceblock:active + {opacity:.55}` als Tipp-Rückmeldung ergänzt, da der übliche `.tilebtn`-Hintergrundwechsel + hier nicht geht), synchron in `installationspaket/`. Entwurf und Begründung als Artefakt: + „Zwei Kacheln, drei Antworten“. +- [x] **Parity rule eingeführt (2026-08-17):** der Nutzer stellte klar, dass Panel und + `companion-app/` immer denselben funktionalen Stand haben sollen — auch wenn eine Anfrage nur + das Panel nennt, das ist reiner Testkomfort (Docker-Instanz), keine Scope-Entscheidung. Regel + als verbindlicher Absatz direkt unter der Maintenance-Regel oben in dieser Datei verankert. + Als erste Anwendung sofort im selben Zug die Übersicht-Neugestaltung von oben nach + `companion-app/` portiert (siehe `Uebersicht.tsx`): `naechsterService()` in `daten/service.ts` + neu — Gegenstück zu `naechsterTermin()` im Panel, wählt über Ölwechsel-/Inspektions-Prognose + und die von Hand gepflegte Hauptuntersuchung hinweg den zeitlich nächsten Termin, `bisText()` + liefert denselben Artikel wie `ART_BIS` im Panel. Die beiden bisherigen Kacheln + "Reichweite"/"Kilometerstand" nebeneinander plus eine separate "Service"-Kachel mit + Werteliste wichen 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. `npm run typecheck` blieb sauber, `npm run test` auf + 100/100 (von 95 — vier neue Tests für `naechsterService()`/`bisText()` in `service.test.ts`, + einer 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 + — dafür musste `zeige()` in `screens.test.tsx` erst einen injizierbaren `geheZu`-Parameter + bekommen, vorher war er hart auf `() => {}` verdrahtet), `npm run build` erfolgreich. - [ ] Fix remaining documentation drift (statistics claim, README gaps, obsolete TODO comment) — text-only changes; INSTALL.md's WLAN/TommiG1 drift and stale variable names were fixed 2026-08-12 (see section B); `DESIGN_REVIEW_2026-08-13.md` and `REVIEW_main_2026-08-13.md` diff --git a/companion-app/src/daten/service.test.ts b/companion-app/src/daten/service.test.ts index cadcf13..96f9847 100644 --- a/companion-app/src/daten/service.test.ts +++ b/companion-app/src/daten/service.test.ts @@ -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") + }) +}) diff --git a/companion-app/src/daten/service.ts b/companion-app/src/daten/service.ts index 3500c09..56e2c46 100644 --- a/companion-app/src/daten/service.ts +++ b/companion-app/src/daten/service.ts @@ -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 = { + "Ö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 +} diff --git a/companion-app/src/screens/Uebersicht.tsx b/companion-app/src/screens/Uebersicht.tsx index b796ed7..c09ea31 100644 --- a/companion-app/src/screens/Uebersicht.tsx +++ b/companion-app/src/screens/Uebersicht.tsx @@ -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")} /> -
- - Reichweite - - -
- {de(fahrzeug.tankPct)} % im Tank - - rund {de((einstellungen.tankvolumen * fahrzeug.tankPct) / 100, 1)} von{" "} - {de(einstellungen.tankvolumen)} l - -
-
+ + Reichweite + + +
+ {de(fahrzeug.tankPct)} % im Tank + + rund {de((einstellungen.tankvolumen * fahrzeug.tankPct) / 100, 1)} von{" "} + {de(einstellungen.tankvolumen)} l + +
+
- - Kilometerstand - - -
+ {/* 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). */} - geheZu("service")}> - Service - + +
+ Kilometerstand + +
diff --git a/companion-app/src/screens/screens.test.tsx b/companion-app/src/screens/screens.test.tsx index 11abb4c..be522ac 100644 --- a/companion-app/src/screens/screens.test.tsx +++ b/companion-app/src/screens/screens.test.tsx @@ -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(
{}} + geheZu={geheZu} tabBeschriftung > {}} + 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()) diff --git a/companion-app/src/stile/screens.css b/companion-app/src/stile/screens.css index aa37eee..b01901a 100644 --- a/companion-app/src/stile/screens.css +++ b/companion-app/src/stile/screens.css @@ -185,6 +185,50 @@ padding: var(--sp-4) var(--sp-4) var(--sp-5); } +/* Übersicht: "Nächster Service" als tippbare Kennzahl innerhalb der Kachel, + darunter der Kilometerstand als ruhige Wertezeile. Nur der obere Teil ist + ein Knopf, damit "differentiate controls from content" (Apple HIG) gilt - + deckungsgleich mit .serviceblock/.servicezeile im Panel + (audi-dashboard.css). */ +.dm-serviceblock { + position: relative; + display: block; + width: 100%; + border: 0; + background: none; + padding: 0; + margin: 0; + font: inherit; + text-align: left; + color: inherit; + cursor: pointer; +} +.dm-serviceblock:active { + opacity: 0.55; +} +.dm-serviceblock__chevron { + position: absolute; + top: 2px; + right: 0; + font-size: 15px; + line-height: 1; + color: var(--fg3); +} +.dm-servicezeile { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--sp-4); + margin-top: var(--sp-3); + padding-top: 13px; + border-top: 1px solid var(--line); +} +.dm-servicezeile__k { + font-size: 13px; + font-weight: 300; + color: var(--fg2); +} + .dm-tanktext { display: flex; justify-content: space-between;