diff --git a/AGENTS.md b/AGENTS.md index bd0217a..f4dd5d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,10 @@ dead-ends in the bootstrap retry loop behind the reported hanging "Lädt …" sc round the same day replaced the trip form's editable "Dauer" with Start-/Ankunftszeit (duration is now computed, not entered), capitalized the Art labels shown to the user, dropped the red styling and hint text from the Arbeitsweg pill, and fixed a daylight-mode-only regression from round twelve -where the image placeholder's grey background had turned invisible-white). +where the image placeholder's grey background had turned invisible-white; separately the same day, +`companion-app/` (the actual phone-app codebase, which had silently drifted six days behind the +panel) was brought back in sync with everything the panel gained since 2026-08-11 - see the +"Gap found while auditing..." entry near the end of section C). This file is the entry point for every new agent session: what this repo is, what is finished, what is missing, and how to work here. Detail lives in the linked documents — this file points, it does not duplicate. @@ -1322,16 +1325,61 @@ wraps the web app for iPhone; a PWA home-screen install is the accepted intermed All five deployed to `audi_ha_test` (version `1787009000`) and synced to `installationspaket/`; console showed only the pre-existing service-worker/404 noise from the missing vehicle photos, nothing new. -- [ ] **New gap found while auditing "what's needed for a real iPhone app" (2026-08-17):** - `companion-app/src/screens/` has not been touched since 2026-08-11 (`git log -1` on that - path) — every panel fix and feature from the six days of sessions since (the full HIG audit - rounds, receipt drag/paste upload, trip editing, Ankunftszeit, Inspektion forecast, - Heckklappenschloss removal, the Tag-theme placeholder fix, all of it) exists **only** in - `homeassistant/www/audi-dashboard-app.js`, not in the companion app. The two codebases are - independent implementations of the same product, not a shared component — nothing in this - repo currently keeps them in sync automatically. Whoever next works on the companion app - needs to either treat this as a deliberate "port everything since 2026-08-11" task, or - explicitly decide the divergence is acceptable for now. Not scoped or started. +- [x] **Gap found while auditing "what's needed for a real iPhone app" (2026-08-17), then closed + the same day:** `companion-app/src/screens/` hadn't been touched since 2026-08-11 — every + panel fix and feature from the six days since (HIG audit rounds, receipt upload, trip + editing, Inspektion forecast, ...) existed only in `homeassistant/www/audi-dashboard-app.js`. + User decision: port everything now, before any native build work (Android build itself + deprioritized/uncertain per the user - Paul has an Apple Developer Program and will help with + iOS signing). Audited both codebases field-by-field (not by diffing the panel's CSS/markup, + which doesn't transfer to `@audi-dash/ui` components) and found six real, mechanically + distinct gaps, all fixed: + 1. **`AGM_RUHE_MAX_V` was still 13.2** in `Batterie.tsx` - lowered to 12.8, matching the + panel's 2026-08-16 fix (same rationale: above that is already alternator voltage). + 2. **Dead `wlan_name` field** - `profilAdapter.ts` still read/wrote it and `Einstellungen.tsx` + showed it as an editable "WLAN des Fahrzeugs" field, even though WLAN-based trip detection + was fully removed from the backend on 2026-08-12 (replaced by FMM003 ignition). Editing it + had zero effect. Removed from the type, both directions of the adapter, the settings + screen, and both test fixtures. + 3. **No Inspektion forecast** - `service.ts` only had `oelwechselPrognose()`. Refactored the + shared math into a `servicePrognose()` core (kept `oelwechselPrognose()`'s public + signature/behavior identical - all 9 existing tests still pass unchanged) and added + `inspektionPrognose()` (fixed 30.000 km / 24 Monate, no customizable interval like the oil + change has), wired into `Service.tsx` as a new "Eigene Prognose (Inspektion)" row. 3 new + tests. + 4. **Arbeitsweg pill was red** - `Pill variant="work"` in `Fahrten.tsx`/`FahrtDetail.tsx`; + `@audi-dash/ui`'s own source comment says the variant literally "recolors border+text to + the accent red". Stopped passing the variant in both spots - same fix as the panel, no + change needed to the shared design-system component itself. + 5. **Trip creation only took Beginn/Ende/Art** - the panel's fuller field set (Startort, + Zielort, Start-/Endkilometer, Distanz) didn't exist here. New shared + `src/screens/FahrtFelder.tsx` (type, defaults, backend-payload conversion, and the + presentational field group) used by both the create form (`Fahrten.tsx`) and a **new** + edit capability. + 6. **`FahrtDetail.tsx` was entirely read-only** - no edit, no Art toggle after creation. Added + a "Bearbeiten" toggle reusing `FahrtFelder.tsx`, backed by a new `DataMetricApi + .fahrtAktualisieren()` (mirrors the existing `tankvorgangAktualisieren()` pattern) calling + the backend's `audi_dashboard_fahrt_aktualisieren` service (built this session on the panel + side, so the backend contract already existed and needed no changes). + **Explicitly investigated and found NOT to apply** (worth recording so it isn't re-attempted): + price-per-litre rounding (companion-app's `eur()` already always renders 2 decimals - the + 3-decimal bug the panel had never existed here); pull-to-refresh `overscroll-behavior` (no + native pull gesture implementation here to fix); the panel's drag/paste/file-picker receipt + dialog (solves a **desktop browser clipboard** problem that doesn't exist in a Capacitor + native app - the plain `` already in `TankDetail.tsx` gets iOS's native + Files/Photos picker for free, which already covers "a PDF saved from Mail"); every + Setup-menu/entity-mapping feature (HA-panel-only concept, the companion app consumes the + backend's already-resolved published state and has no equivalent screen, nor should it); + the many purely cosmetic HIG-audit CSS fixes (icon fills, chevrons, tab-bar highlight + bugs, ...) - those target the panel's own hand-written markup/CSS, not + `@audi-dash/ui`'s components, and don't transfer. + Verified after every change: `npm run typecheck` (clean throughout), `npm run test` (95/95 + passing, up from 90 - added interaction tests for the new create-form fields and the + edit-toggle pre-fill, plus 3 for `inspektionPrognose()`), `npm run build` (succeeds). Also + corrected a stale README claim ("Capacitor-Hülle und sichere Ablage" both listed as open) - + the secure-storage adapter is actually fully wired (`ablageNativ.ts`, activated from + `main.tsx`) and only the native `ios/`/`android/` platform folders themselves (deliberately + gitignored, regenerated via `npx cap add`) are the missing piece, and that step needs a Mac. - [ ] 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/README.md b/companion-app/README.md index ae33d4f..34cc009 100644 --- a/companion-app/README.md +++ b/companion-app/README.md @@ -70,7 +70,12 @@ dort niemals Markendateien ablegen. ## Noch offen -- Capacitor-Hülle und sichere Ablage des Tokens (Keychain/Keystore) +- Native Hülle selbst: `ios/`/`android/` (von `npx cap add` erzeugt, absichtlich + gitignored) existieren in keinem Checkout dauerhaft und müssen auf einem Mac + (für iOS zwingend, Xcode) neu erzeugt werden - die sichere Tokenablage + (Keychain/Keystore) ist dagegen bereits vollständig angebunden + (`src/api/ablageNativ.ts`, von `main.tsx` aktiviert) und wird automatisch + aktiv, sobald die Hülle existiert - QR-Einrichtung als Alternative zum Einfügen des Tokens - Live-Ansicht der laufenden Fahrt: gebaut, aber über `src/funktionen.ts` abgeschaltet, bis der FMM003 echte Werte liefert diff --git a/companion-app/src/api/index.ts b/companion-app/src/api/index.ts index 0ef1451..dc4386a 100644 --- a/companion-app/src/api/index.ts +++ b/companion-app/src/api/index.ts @@ -33,6 +33,21 @@ export interface TankvorgangFelder { kraftstoff?: string | null; } +/** Eingabefelder für Fahrten - Namen wie beim Backend (fahrterkennung.py: + audi_dashboard_fahrt_manuell_anlegen/audi_dashboard_fahrt_aktualisieren). + Alles außer den Zeitpunkten ist optional: leer bleibt es liegen, bis das + Kilometerstand-Screening (§7.2) oder eine spätere Bearbeitung es füllt. */ +export interface FahrtFelder { + ts_start?: string; + ts_end?: string; + art?: "privat" | "arbeitsweg"; + start_ort?: string | null; + ziel_ort?: string | null; + odo_start?: number | null; + odo_end?: number | null; + distanz?: number | null; +} + /** Bündelt die drei Bausteine und bildet die Fachvorgänge ab, die das alte Dashboard über hass.callService erledigt hat. */ export class DataMetricApi { @@ -94,15 +109,26 @@ export class DataMetricApi { } /** Legt eine Fahrt von Hand an — für Fahrten ohne automatische Erkennung. */ - fahrtAnlegen(tsStart: string, tsEnde: string, art: "privat" | "arbeitsweg"): Promise { + fahrtAnlegen(felder: FahrtFelder): Promise { return this.warteschlange.einreihen( "pyscript", "audi_dashboard_fahrt_manuell_anlegen", - { ts_start: tsStart, ts_end: tsEnde, art }, + { ...felder }, "Fahrt eintragen", ); } + /** Bearbeitet eine bestehende Fahrt, gleich ob automatisch erkannt oder von + Hand angelegt. */ + fahrtAktualisieren(tripId: string, felder: FahrtFelder): Promise { + return this.warteschlange.einreihen( + "pyscript", + "audi_dashboard_fahrt_aktualisieren", + { trip_id: tripId, ...felder }, + "Fahrt ändern", + ); + } + fahrtLoeschen(tripId: string): Promise { return this.warteschlange.einreihen( "pyscript", diff --git a/companion-app/src/daten/profilAdapter.test.ts b/companion-app/src/daten/profilAdapter.test.ts index 108ba70..e4957e4 100644 --- a/companion-app/src/daten/profilAdapter.test.ts +++ b/companion-app/src/daten/profilAdapter.test.ts @@ -19,7 +19,6 @@ function beispielprofil(aenderung: Partial = {}): Profil { untertitel: "quattro · 331 kW", kennzeichen: "M AB 1234", tankvolumen_liter: 58, - wlan_name: "Audi_MMI", }, einstellungen: { uebersichtsbild: "seitenansicht.webp", diff --git a/companion-app/src/daten/profilAdapter.ts b/companion-app/src/daten/profilAdapter.ts index 75e0ddb..0f7326e 100644 --- a/companion-app/src/daten/profilAdapter.ts +++ b/companion-app/src/daten/profilAdapter.ts @@ -21,7 +21,6 @@ export interface Einstellungen { nachtBis: string pauseMin: number markenname: string - wlanName: string backupIntervall: string letztesBackup: string | null ausfuehrung: string @@ -114,7 +113,6 @@ export function profilZuEinstellungen(profil: Profil): Einstellungen { nachtBis: text(einst, "nacht_bis", "06:00"), pauseMin: zahl(einst, "fahrten_pausenzeit_min") ?? 15, markenname: text(einst, "kraftstoffanbieter", "Shell"), - wlanName: text(fahrzeug, "wlan_name"), backupIntervall: text(einst, "backup_intervall", "aus"), letztesBackup: text(einst, "letztes_backup") || null, ausfuehrung: text(fahrzeug, "ausfuehrung"), @@ -199,7 +197,6 @@ export function zusammenfuehren( f["zusatz"] = einstellungen.fahrzeugzusatz f["untertitel"] = einstellungen.fahrzeuguntertitel f["kennzeichen"] = einstellungen.kennzeichen - f["wlan_name"] = einstellungen.wlanName f["ausfuehrung"] = einstellungen.ausfuehrung f["tankvolumen_liter"] = einstellungen.tankvolumen f["fin"] = fahrzeug.fin diff --git a/companion-app/src/daten/service.test.ts b/companion-app/src/daten/service.test.ts index 736b940..cadcf13 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 { letzterOelwechsel, oelwechselPrognose } from "./service" +import { inspektionPrognose, letzterOelwechsel, oelwechselPrognose } from "./service" import type { Fahrzeug } from "./profilAdapter" function fahrzeug(odo: number, modus = "hersteller"): Fahrzeug { @@ -92,3 +92,23 @@ describe("oelwechselPrognose", () => { expect(oelwechselPrognose(fahrzeug(46000), [], jetzt)).toBeNull() }) }) + +describe("inspektionPrognose", () => { + const jetzt = new Date("2026-08-11T12:00:00") + + it("rechnet mit dem festen Intervall 30.000 km / 24 Monate, nicht dem Ölwechsel-Intervall", () => { + // Letzte Inspektion 05.03.2025 bei 25000 km -> Ziel 55000. Bei 46000 km + // sind das 9000 km offen, unabhängig vom oel-Feld des Fahrzeugs (15000/12). + const p = inspektionPrognose(fahrzeug(46000), BUCH, jetzt) + expect(p).not.toBeNull() + expect(p!.restKm).toBe(9000) + }) + + it("ignoriert den Ölwechsel-Eintrag bei der Suche nach der letzten Inspektion", () => { + expect(inspektionPrognose(fahrzeug(46000), [BUCH[0]!], jetzt)).toBeNull() + }) + + it("liefert nichts, wenn nie eine Inspektion eingetragen wurde", () => { + expect(inspektionPrognose(fahrzeug(46000), [], jetzt)).toBeNull() + }) +}) diff --git a/companion-app/src/daten/service.ts b/companion-app/src/daten/service.ts index 7f4a965..3500c09 100644 --- a/companion-app/src/daten/service.ts +++ b/companion-app/src/daten/service.ts @@ -1,13 +1,14 @@ /** - * Eigene Ölwechsel-Prognose. Portiert aus `oelwechselPrognose()` im alten - * Panel. + * Eigene Service-Prognosen (Ölwechsel, Inspektion). Portiert aus + * `oelwechselPrognose()` im alten Panel; die Inspektion kam am 2026-08-17 + * hinzu (§ AGENTS.md, "Inspektion: voraussichtliches Datum ergänzen"). * * Die Rechnung beginnt bewusst neu ab dem letzten tatsächlich durchgeführten - * Ölwechsel — der allgemeine Langzeitdurchschnitt seit dem allerersten - * Servicebucheintrag wäre dafür zu träge. Aus km und Tagen seit dem letzten - * Wechsel ergibt sich die aktuelle Fahrleistung pro Tag; hochgerechnet auf die - * verbleibenden km bis zum eingestellten Intervall ergibt das ein Datum, - * gedeckelt auf spätestens das Zeitlimit des Intervalls. + * Eintrag dieser Art — der allgemeine Langzeitdurchschnitt seit dem + * allerersten Servicebucheintrag wäre dafür zu träge. Aus km und Tagen seit + * dem letzten Eintrag ergibt sich die aktuelle Fahrleistung pro Tag; + * hochgerechnet auf die verbleibenden km bis zum Intervall ergibt das ein + * Datum, gedeckelt auf spätestens das Zeitlimit des Intervalls. */ import type { Fahrzeug } from "./profilAdapter" @@ -36,33 +37,31 @@ function monatePlus(isoDatum: string, monate: number): string { return d.toISOString().slice(0, 10) } -/** Der jüngste Servicebucheintrag, dessen Art auf einen Ölwechsel hindeutet. */ -export function letzterOelwechsel(buch: readonly Buchhaltung[]): Buchhaltung | null { +/** Der jüngste Servicebucheintrag, dessen Art auf das gesuchte Stichwort + passt (z. B. "öl"/"oel" oder "inspektion"). */ +function letzterEintrag(buch: readonly Buchhaltung[], stichwort: RegExp): Buchhaltung | null { const treffer = buch - .filter((e) => typeof e.art === "string" && /öl|oel/i.test(e.art) && e.datum && e.km != null) + .filter((e) => typeof e.art === "string" && stichwort.test(e.art) && e.datum && e.km != null) .sort((a, b) => String(b.datum).localeCompare(String(a.datum))) return treffer[0] ?? null } -export function oelwechselPrognose( +/** Der jüngste Servicebucheintrag, dessen Art auf einen Ölwechsel hindeutet. */ +export function letzterOelwechsel(buch: readonly Buchhaltung[]): Buchhaltung | null { + return letzterEintrag(buch, /öl|oel/i) +} + +/** Gemeinsamer Kern für Ölwechsel- und Inspektionsprognose - beide rechnen + identisch, nur mit anderem Intervall und anderer Suche im Servicebuch. */ +function servicePrognose( fahrzeug: Fahrzeug, - buch: readonly Buchhaltung[], - jetzt = new Date(), + letzter: Buchhaltung | null, + intervallKm: number, + intervallMonate: number, + jetzt: Date, ): Oelprognose | null { if (!fahrzeug.odoBekannt) return null - const letzter = letzterOelwechsel(buch) if (!letzter?.datum || letzter.km == null) return null - - const oel = fahrzeug.oel - const intervallKm = - oel.modus === "hersteller" - ? (oel.herstellerKm ?? 0) - : Math.min(oel.km ?? Infinity, oel.herstellerKm ?? Infinity) - const intervallMonate = - oel.modus === "hersteller" - ? (oel.herstellerMonate ?? 0) - : Math.min(oel.monate ?? Infinity, oel.herstellerMonate ?? Infinity) - if (!Number.isFinite(intervallKm) || intervallKm <= 0) return null const zielKm = letzter.km + intervallKm @@ -76,7 +75,7 @@ export function oelwechselPrognose( const tageSeit = (jetzt.getTime() - new Date(letzter.datum).getTime()) / TAG_MS const kmSeit = fahrzeug.odo - letzter.km - // Ohne belastbare Fahrleistung (direkt nach dem Wechsel) gibt es keine + // Ohne belastbare Fahrleistung (direkt nach dem Eintrag) gibt es keine // seriöse Kilometerprognose — dann gilt das Zeitlimit. if (tageSeit <= 0 || kmSeit <= 0) { return { datum: kappDatum, restKm, kmProTag: 0, durchZeitlimit: true } @@ -94,3 +93,30 @@ export function oelwechselPrognose( durchZeitlimit, } } + +export function oelwechselPrognose( + fahrzeug: Fahrzeug, + buch: readonly Buchhaltung[], + jetzt = new Date(), +): Oelprognose | null { + const oel = fahrzeug.oel + const intervallKm = + oel.modus === "hersteller" + ? (oel.herstellerKm ?? 0) + : Math.min(oel.km ?? Infinity, oel.herstellerKm ?? Infinity) + const intervallMonate = + oel.modus === "hersteller" + ? (oel.herstellerMonate ?? 0) + : Math.min(oel.monate ?? Infinity, oel.herstellerMonate ?? Infinity) + return servicePrognose(fahrzeug, letzterOelwechsel(buch), intervallKm, intervallMonate, jetzt) +} + +/** Inspektion: feste 30.000 km / 24 Monate, wie auch das Fahrzeug selbst und + das alte Panel rechnen - kein einstellbares Intervall wie beim Ölwechsel. */ +export function inspektionPrognose( + fahrzeug: Fahrzeug, + buch: readonly Buchhaltung[], + jetzt = new Date(), +): Oelprognose | null { + return servicePrognose(fahrzeug, letzterEintrag(buch, /inspektion/i), 30000, 24, jetzt) +} diff --git a/companion-app/src/screens/Batterie.tsx b/companion-app/src/screens/Batterie.tsx index d46dbb0..5518454 100644 --- a/companion-app/src/screens/Batterie.tsx +++ b/companion-app/src/screens/Batterie.tsx @@ -7,7 +7,7 @@ * * Wichtig für die Einordnung: Der Ladezustand wird aus der **Tagesminimum**- * Spannung geschätzt (Ruhespannung kurz nach dem Start, bevor die Lichtmaschine - * anhebt); Werte über 13,2 V gelten als „während der Fahrt gemessen" und + * anhebt); Werte über 12,8 V gelten als „während der Fahrt gemessen" und * taugen nicht als Ruhewert. */ @@ -28,8 +28,9 @@ interface Tageswert { max_ts?: string } -/** Ab hier gilt eine Messung als nicht mehr in Ruhe aufgenommen. */ -const AGM_RUHE_MAX_V = 13.2 +/** Ab hier gilt eine Messung als nicht mehr in Ruhe aufgenommen - alles + darüber ist schon Lichtmaschinenspannung, nicht mehr Ruhespannung. */ +const AGM_RUHE_MAX_V = 12.8 /** Spannung → Ladezustand für eine AGM-Batterie in Ruhe. */ const LADEKURVE: [number, number][] = [ diff --git a/companion-app/src/screens/Einstellungen.tsx b/companion-app/src/screens/Einstellungen.tsx index 31f876e..37c3c48 100644 --- a/companion-app/src/screens/Einstellungen.tsx +++ b/companion-app/src/screens/Einstellungen.tsx @@ -102,7 +102,7 @@ export function Einstellungen({ onChange={(e) => aendern({ kennzeichen: e.target.value })} /> - + aendern({ tankvolumen: Number(e.target.value) || 0 })} /> - - aendern({ wlanName: e.target.value })} - /> - diff --git a/companion-app/src/screens/FahrtDetail.tsx b/companion-app/src/screens/FahrtDetail.tsx index a6a389d..1406a0a 100644 --- a/companion-app/src/screens/FahrtDetail.tsx +++ b/companion-app/src/screens/FahrtDetail.tsx @@ -9,16 +9,29 @@ * ist, statt eine glaubwürdig aussehende Erfindung zu zeigen. */ -import { Pill, Tile } from "@audi-dash/ui" +import { useState } from "react" +import { ActionButton, Feld, Pill, Tile } from "@audi-dash/ui" + +import type { Fahrt } from "../api" import { useDaten } from "../daten/DatenKontext" import { datum, dauer, de, deOderStrich, uhrzeit } from "../format" import { Leerzustand, Wertzeile, Werteliste } from "./bausteine" +import { FahrtFelderFormular, fahrtFelderAusFahrt, fahrtFelderAuswerten } from "./FahrtFelder" import { Karte } from "./Karte" +/** "YYYY-MM-DDTHH:mm" in Ortszeit für ein . */ +function alsDatetimeLocal(iso: string): string { + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return "" + const versatz = d.getTimezoneOffset() * 60_000 + return new Date(d.getTime() - versatz).toISOString().slice(0, 16) +} + export function FahrtDetail({ id }: { id: string | undefined }) { const { fahrten } = useDaten() const fahrt = fahrten.find((f) => f.trip_id === id) + const [bearbeitenOffen, setzeBearbeitenOffen] = useState(false) if (!fahrt) { return ( @@ -29,21 +42,30 @@ export function FahrtDetail({ id }: { id: string | undefined }) { ) } + if (bearbeitenOffen) { + return setzeBearbeitenOffen(false)} /> + } + const hatPositionen = fahrt.start_lat != null && fahrt.start_lon != null return ( <> - - {datum(fahrt.ts_start)} · {uhrzeit(fahrt.ts_start)} bis {uhrzeit(fahrt.ts_end)} Uhr - +
+ + {datum(fahrt.ts_start)} · {uhrzeit(fahrt.ts_start)} bis {uhrzeit(fahrt.ts_end)} Uhr + + +
{deOderStrich(fahrt.distance_km, 1)} km
- {fahrt.art === "arbeitsweg" ? Arbeitsweg : Privat} + {fahrt.art === "arbeitsweg" ? "Arbeitsweg" : "Privat"} {fahrt.status === "offen" && noch offen} {fahrt.source === "manual" && selbst eingetragen}
@@ -109,3 +131,77 @@ export function FahrtDetail({ id }: { id: string | undefined }) { ) } + +/** Bearbeiten-Formular für eine bestehende Fahrt - dieselbe Feldgruppe wie + beim Neuanlegen (FahrtFelder.tsx), vorbelegt mit den aktuellen Werten. */ +function FahrtBearbeiten({ fahrt, beiFertig }: { fahrt: Fahrt; beiFertig: () => void }) { + const { api } = useDaten() + const [start, setzeStart] = useState(alsDatetimeLocal(fahrt.ts_start)) + const [ende, setzeEnde] = useState(alsDatetimeLocal(fahrt.ts_end)) + const [felder, setzeFelder] = useState(fahrtFelderAusFahrt(fahrt)) + const [fehler, setzeFehler] = useState(null) + const [laeuft, setzeLaeuft] = useState(false) + + const speichern = async () => { + setzeFehler(null) + if (!start || !ende) { + setzeFehler("Bitte Beginn und Ende angeben.") + return + } + if (new Date(ende) <= new Date(start)) { + setzeFehler("Das Ende muss nach dem Beginn liegen.") + return + } + setzeLaeuft(true) + try { + await api.fahrtAktualisieren(fahrt.trip_id, { + ts_start: new Date(start).toISOString(), + ts_end: new Date(ende).toISOString(), + ...fahrtFelderAuswerten(felder), + }) + beiFertig() + } catch (ursache) { + setzeFehler(ursache instanceof Error ? ursache.message : String(ursache)) + } finally { + setzeLaeuft(false) + } + } + + return ( + +
+ Fahrt bearbeiten + +
+ + setzeStart(e.target.value)} + /> + + + setzeEnde(e.target.value)} + /> + + + {fehler && ( +

+ {fehler} +

+ )} +
+ void speichern()} disabled={laeuft}> + {laeuft ? "Speichere …" : "Speichern"} + +
+
+ ) +} diff --git a/companion-app/src/screens/FahrtFelder.tsx b/companion-app/src/screens/FahrtFelder.tsx new file mode 100644 index 0000000..9d91374 --- /dev/null +++ b/companion-app/src/screens/FahrtFelder.tsx @@ -0,0 +1,114 @@ +/** + * Die Felder einer Fahrt jenseits von Zeitpunkt und Dauer - geteilt zwischen + * dem Neuanlegen (Fahrten.tsx) und dem Bearbeiten (FahrtDetail.tsx), damit + * beide Formulare nicht auseinanderlaufen. Vorlage: `fahrtFelder()` im + * Panel (audi-dashboard-app.js). + */ + +import { Feld } from "@audi-dash/ui" + +import type { FahrtFelder as FahrtFelderApi } from "../api" + +export interface FahrtFelderEingabe { + art: "privat" | "arbeitsweg" + startOrt: string + zielOrt: string + odoStart: string + odoEnde: string + distanz: string +} + +export const LEERE_FAHRT_FELDER: FahrtFelderEingabe = { + art: "privat", + startOrt: "", + zielOrt: "", + odoStart: "", + odoEnde: "", + distanz: "", +} + +/** Vorbelegung aus einer bestehenden Fahrt fürs Bearbeiten-Formular. */ +export function fahrtFelderAusFahrt(fahrt: { + art: "privat" | "arbeitsweg" + start_address?: string | null + end_address?: string | null + odo_start?: number | null + odo_end?: number | null + distance_km?: number | null +}): FahrtFelderEingabe { + return { + art: fahrt.art, + startOrt: fahrt.start_address ?? "", + zielOrt: fahrt.end_address ?? "", + odoStart: fahrt.odo_start != null ? String(fahrt.odo_start) : "", + odoEnde: fahrt.odo_end != null ? String(fahrt.odo_end) : "", + distanz: fahrt.distance_km != null ? String(fahrt.distance_km) : "", + } +} + +/** Wandelt die Formulareingabe in die Felder um, die das Backend erwartet - + leere Felder werden als "nicht angegeben" (undefined) übertragen, nicht + als 0, sonst würde ein leeres Kilometerfeld einen echten Wert überschreiben. */ +export function fahrtFelderAuswerten( + eingabe: FahrtFelderEingabe, +): Pick { + const zahl = (wert: string): number | null => { + if (wert.trim() === "") return null + const n = Number(wert.replace(",", ".")) + return Number.isFinite(n) ? n : null + } + return { + art: eingabe.art, + start_ort: eingabe.startOrt.trim() || null, + ziel_ort: eingabe.zielOrt.trim() || null, + odo_start: zahl(eingabe.odoStart), + odo_end: zahl(eingabe.odoEnde), + distanz: zahl(eingabe.distanz), + } +} + +export function FahrtFelderFormular({ + werte, + setzeWerte, +}: { + werte: FahrtFelderEingabe + setzeWerte: (aendern: (alt: FahrtFelderEingabe) => FahrtFelderEingabe) => void +}) { + const feld = (schluessel: K) => ({ + value: werte[schluessel], + onChange: (e: React.ChangeEvent) => + setzeWerte((alt) => ({ ...alt, [schluessel]: e.target.value })), + }) + + return ( + <> + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/companion-app/src/screens/Fahrten.tsx b/companion-app/src/screens/Fahrten.tsx index 4eb0f99..b5cb97f 100644 --- a/companion-app/src/screens/Fahrten.tsx +++ b/companion-app/src/screens/Fahrten.tsx @@ -12,6 +12,8 @@ import { nachJahrUndMonat } from "../daten/statistik" import { datum, dauer, de, deOderStrich, monatJahr, summe, uhrzeit } from "../format" import type { SeitenName } from "../navigation" import { Leerzustand } from "./bausteine" +import { FahrtFelderFormular, LEERE_FAHRT_FELDER, fahrtFelderAuswerten } from "./FahrtFelder" +import type { FahrtFelderEingabe } from "./FahrtFelder" import { Zeilenmenue } from "./Zeilenmenue" export function Fahrten({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) { @@ -82,7 +84,7 @@ export function Fahrten({ geheZu }: { geheZu: (name: SeitenName, id?: string) => {dauer(fahrt.duration_s)} - {fahrt.art === "arbeitsweg" && Arbeitsweg} + {fahrt.art === "arbeitsweg" && Arbeitsweg} {fahrt.status === "offen" && offen} } @@ -96,12 +98,15 @@ export function Fahrten({ geheZu }: { geheZu: (name: SeitenName, id?: string) => ) } -/** Manuelles Eintragen — für Fahrten, die die automatische Erkennung verpasst hat. */ +/** Manuelles Eintragen — für Fahrten, die die automatische Erkennung verpasst hat. + Dieselben Felder wie beim Bearbeiten einer bestehenden Fahrt (siehe + FahrtFelderFormular in FahrtDetail.tsx) - beide teilen sich deshalb die + Feldgruppe. */ function FahrtFormular({ beiFertig }: { beiFertig: () => void }) { const { api } = useDaten() const [start, setzeStart] = useState("") const [ende, setzeEnde] = useState("") - const [art, setzeArt] = useState<"privat" | "arbeitsweg">("privat") + const [felder, setzeFelder] = useState(LEERE_FAHRT_FELDER) const [fehler, setzeFehler] = useState(null) const [laeuft, setzeLaeuft] = useState(false) @@ -117,7 +122,11 @@ function FahrtFormular({ beiFertig }: { beiFertig: () => void }) { } setzeLaeuft(true) try { - await api.fahrtAnlegen(new Date(start).toISOString(), new Date(ende).toISOString(), art) + await api.fahrtAnlegen({ + ts_start: new Date(start).toISOString(), + ts_end: new Date(ende).toISOString(), + ...fahrtFelderAuswerten(felder), + }) beiFertig() } catch (ursache) { setzeFehler(ursache instanceof Error ? ursache.message : String(ursache)) @@ -145,16 +154,7 @@ function FahrtFormular({ beiFertig }: { beiFertig: () => void }) { onChange={(e) => setzeEnde(e.target.value)} /> - - - + {fehler && (

{fehler} diff --git a/companion-app/src/screens/Service.tsx b/companion-app/src/screens/Service.tsx index 55e7a86..fd8f696 100644 --- a/companion-app/src/screens/Service.tsx +++ b/companion-app/src/screens/Service.tsx @@ -13,7 +13,7 @@ import { useState } from "react" import { ActionButton, Feld, Tile } from "@audi-dash/ui" import { useDaten } from "../daten/DatenKontext" -import { oelwechselPrognose } from "../daten/service" +import { inspektionPrognose, oelwechselPrognose } from "../daten/service" import { datum, de, isoTag } from "../format" import type { SeitenName } from "../navigation" import { Leerzustand, NaviKachel, Wertzeile, Werteliste, bestaetigen } from "./bausteine" @@ -42,6 +42,7 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) => const buch = (fahrzeug.service["buch"] ?? []) as ServicebuchEintrag[] const autohaus = (fahrzeug.service["autohaus"] ?? {}) as Autohaus const prognose = oelwechselPrognose(fahrzeug, buch) + const inspektionsprognose = inspektionPrognose(fahrzeug, buch) return ( <> @@ -77,6 +78,12 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) => : undefined } /> + {inspektionsprognose && ( + + )} { await waitFor(() => expect(screen.getByText("48,70")).toBeTruthy()) expect(screen.getByText("1,83 €")).toBeTruthy() }) + + it("bietet beim Eintragen einer Fahrt alle Felder, nicht nur Start/Ende/Art", async () => { + zeige("trips") + await waitFor(() => expect(screen.getByText("Fahrt eintragen")).toBeTruthy()) + fireEvent.click(screen.getByText("Fahrt eintragen")) + expect(screen.getByText("Startort")).toBeTruthy() + expect(screen.getByText("Zielort")).toBeTruthy() + expect(screen.getByText("Startkilometer")).toBeTruthy() + expect(screen.getByText("Endkilometer")).toBeTruthy() + expect(screen.getByText("Gefahrene Distanz")).toBeTruthy() + }) + + it("lässt eine bestehende Fahrt bearbeiten, vorbelegt mit ihren Werten", async () => { + zeige("trip", "t1") + await waitFor(() => expect(screen.getByText("Bearbeiten")).toBeTruthy()) + fireEvent.click(screen.getByText("Bearbeiten")) + expect(screen.getByText("Fahrt bearbeiten")).toBeTruthy() + // t1 ist "arbeitsweg" (beispieldaten.ts) - das Auswahlfeld muss das übernehmen, + // nicht immer bei "privat" neu anfangen. + const artAuswahl = screen.getByDisplayValue("Arbeitsweg") as HTMLSelectElement + expect(artAuswahl.value).toBe("arbeitsweg") + }) }) diff --git a/companion-app/src/tests/beispieldaten.ts b/companion-app/src/tests/beispieldaten.ts index f7addbb..19f80cc 100644 --- a/companion-app/src/tests/beispieldaten.ts +++ b/companion-app/src/tests/beispieldaten.ts @@ -35,7 +35,6 @@ export const beispielProfil: Profil = { erstzulassung: "2023-04-01", hauptuntersuchung_faellig: "2027-04-01", tankvolumen_liter: 58, - wlan_name: "Audi_MMI", ausfuehrung: "competition", }, einstellungen: { diff --git a/package-lock.json b/package-lock.json index 80eaaf9..9b99e7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -157,7 +157,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -512,7 +511,6 @@ "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.5.0.tgz", "integrity": "sha512-Ca4krtqH1hothjtBIwf2J2TW7IhYq1ujp8QeItTiJohNsqij8ja2DYYH3DU0l8RmxCWaBAFTGA2TgOgOMCSNsQ==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -623,7 +621,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -672,7 +669,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -1776,7 +1772,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2062,6 +2059,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -2082,6 +2080,7 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -2236,7 +2235,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", @@ -2520,6 +2518,7 @@ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -2529,7 +2528,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/electron-to-chromium": { "version": "1.5.404", @@ -2595,7 +2595,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -3036,6 +3035,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -3305,7 +3305,6 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3370,7 +3369,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", @@ -3429,6 +3427,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -3477,7 +3476,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -3490,7 +3488,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -3504,7 +3501,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-refresh": { "version": "0.17.0", @@ -4165,7 +4163,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4273,7 +4270,6 @@ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -5883,7 +5879,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43",