companion-app: catch up six days of panel drift before the native build

The phone-app codebase (companion-app/) hadn't been touched since
2026-08-11 - every panel fix and feature since then (HIG audit rounds,
receipt upload, trip editing, Inspektion forecast, ...) existed only in
the HA panel. Found while auditing what's needed for a real iPhone
build; user decision was to port everything now rather than ship a
stale app.

Six real, verified gaps (not blind copies of panel CSS/markup, which
doesn't transfer to the @audi-dash/ui component set):

- Battery voltage cutoff was still 13.2V, not the panel's 12.8V fix.
- Dead wlan_name field (WLAN trip detection was fully removed from the
  backend 2026-08-12) - removed from the adapter and the settings screen
  instead of leaving a form field that silently does nothing.
- No Inspektion forecast - added inspektionPrognose() alongside the
  existing oelwechselPrognose(), sharing a refactored core.
- Arbeitsweg pill used the design-system's "work" variant, which is
  documented as recoloring to red - stopped passing it, same fix as the
  panel.
- Trip creation only took Beginn/Ende/Art; extended with Startort/
  Zielort/Kilometerstand/Distanz via a new shared FahrtFelder.tsx.
- FahrtDetail.tsx was read-only - added an edit mode using the same
  shared fields, backed by a new DataMetricApi.fahrtAktualisieren()
  calling the backend service built earlier this session.

Explicitly checked and found not applicable: price rounding (already
2 decimals here), pull-to-refresh CSS (no native gesture to fix),
the panel's drag/paste receipt dialog (solves a desktop-browser problem
this native app doesn't have - the plain file picker already gets iOS's
native Files integration), and the purely cosmetic panel CSS fixes.

npm install run at the repo root (node_modules was incomplete/stale),
package-lock.json reflects the real dependency tree. Verified with
npm run typecheck (clean), npm run test (95/95, up from 90 - added
interaction tests for the new form and inspektionPrognose), and
npm run build (succeeds).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 12:16:40 +02:00
parent 188dc8247e
commit edf3845375
16 changed files with 442 additions and 94 deletions
+59 -11
View File
@@ -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 `<input type=file>` 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`
+6 -1
View File
@@ -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
+28 -2
View File
@@ -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<unknown> {
fahrtAnlegen(felder: FahrtFelder): Promise<unknown> {
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<unknown> {
return this.warteschlange.einreihen(
"pyscript",
"audi_dashboard_fahrt_aktualisieren",
{ trip_id: tripId, ...felder },
"Fahrt ändern",
);
}
fahrtLoeschen(tripId: string): Promise<unknown> {
return this.warteschlange.einreihen(
"pyscript",
@@ -19,7 +19,6 @@ function beispielprofil(aenderung: Partial<Profil> = {}): Profil {
untertitel: "quattro · 331 kW",
kennzeichen: "M AB 1234",
tankvolumen_liter: 58,
wlan_name: "Audi_MMI",
},
einstellungen: {
uebersichtsbild: "seitenansicht.webp",
-3
View File
@@ -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
+21 -1
View File
@@ -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()
})
})
+52 -26
View File
@@ -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)
}
+4 -3
View File
@@ -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][] = [
+1 -8
View File
@@ -102,7 +102,7 @@ export function Einstellungen({
onChange={(e) => aendern({ kennzeichen: e.target.value })}
/>
</Feld>
<Feld label="Tankvolumen" unit="l">
<Feld label="Tankvolumen" unit="l" last>
<input
className="dm-eingabe"
inputMode="numeric"
@@ -110,13 +110,6 @@ export function Einstellungen({
onChange={(e) => aendern({ tankvolumen: Number(e.target.value) || 0 })}
/>
</Feld>
<Feld label="WLAN des Fahrzeugs" last>
<input
className="dm-eingabe"
value={werte.wlanName}
onChange={(e) => aendern({ wlanName: e.target.value })}
/>
</Feld>
</Tile>
<Tile>
+101 -5
View File
@@ -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 <input type="datetime-local">. */
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 <FahrtBearbeiten fahrt={fahrt} beiFertig={() => setzeBearbeitenOffen(false)} />
}
const hatPositionen = fahrt.start_lat != null && fahrt.start_lon != null
return (
<>
<Tile>
<span className="ads-eyebrow">
{datum(fahrt.ts_start)} · {uhrzeit(fahrt.ts_start)} bis {uhrzeit(fahrt.ts_end)} Uhr
</span>
<div className="dm-abschnitt" style={{ marginBottom: 0 }}>
<span className="ads-eyebrow">
{datum(fahrt.ts_start)} · {uhrzeit(fahrt.ts_start)} bis {uhrzeit(fahrt.ts_end)} Uhr
</span>
<button type="button" className="dm-textknopf" onClick={() => setzeBearbeitenOffen(true)}>
Bearbeiten
</button>
</div>
<div className="dm-detail__kopf">
<div className="dm-detail__zahl">
{deOderStrich(fahrt.distance_km, 1)}
<span className="dm-detail__einheit">km</span>
</div>
<div className="dm-detail__marken">
{fahrt.art === "arbeitsweg" ? <Pill variant="work">Arbeitsweg</Pill> : <Pill>Privat</Pill>}
<Pill>{fahrt.art === "arbeitsweg" ? "Arbeitsweg" : "Privat"}</Pill>
{fahrt.status === "offen" && <Pill>noch offen</Pill>}
{fahrt.source === "manual" && <Pill>selbst eingetragen</Pill>}
</div>
@@ -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<string | null>(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 (
<Tile>
<div className="dm-abschnitt" style={{ marginBottom: 0 }}>
<span className="ads-eyebrow">Fahrt bearbeiten</span>
<button type="button" className="dm-textknopf" onClick={beiFertig} disabled={laeuft}>
Abbrechen
</button>
</div>
<Feld label="Beginn">
<input
className="dm-eingabe"
type="datetime-local"
value={start}
onChange={(e) => setzeStart(e.target.value)}
/>
</Feld>
<Feld label="Ende">
<input
className="dm-eingabe"
type="datetime-local"
value={ende}
onChange={(e) => setzeEnde(e.target.value)}
/>
</Feld>
<FahrtFelderFormular werte={felder} setzeWerte={setzeFelder} />
{fehler && (
<p className="dm-fehler" role="alert">
{fehler}
</p>
)}
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
{laeuft ? "Speichere …" : "Speichern"}
</ActionButton>
</div>
</Tile>
)
}
+114
View File
@@ -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<FahrtFelderApi, "art" | "start_ort" | "ziel_ort" | "odo_start" | "odo_end" | "distanz"> {
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 = <K extends keyof FahrtFelderEingabe>(schluessel: K) => ({
value: werte[schluessel],
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
setzeWerte((alt) => ({ ...alt, [schluessel]: e.target.value })),
})
return (
<>
<Feld label="Art">
<select
className="dm-auswahl"
value={werte.art}
onChange={(e) =>
setzeWerte((alt) => ({ ...alt, art: e.target.value as "privat" | "arbeitsweg" }))
}
>
<option value="privat">Privat</option>
<option value="arbeitsweg">Arbeitsweg</option>
</select>
</Feld>
<Feld label="Startort">
<input className="dm-eingabe" placeholder="unbekannt" {...feld("startOrt")} />
</Feld>
<Feld label="Zielort">
<input className="dm-eingabe" placeholder="unbekannt" {...feld("zielOrt")} />
</Feld>
<Feld label="Startkilometer" unit="km">
<input className="dm-eingabe" inputMode="numeric" placeholder="wird ergänzt" {...feld("odoStart")} />
</Feld>
<Feld label="Endkilometer" unit="km">
<input className="dm-eingabe" inputMode="numeric" placeholder="wird ergänzt" {...feld("odoEnde")} />
</Feld>
<Feld label="Gefahrene Distanz" unit="km" last>
<input className="dm-eingabe" inputMode="decimal" placeholder="aus km-Stand" {...feld("distanz")} />
</Feld>
</>
)
}
+14 -14
View File
@@ -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)}
</span>
</span>
{fahrt.art === "arbeitsweg" && <Pill variant="work">Arbeitsweg</Pill>}
{fahrt.art === "arbeitsweg" && <Pill>Arbeitsweg</Pill>}
{fahrt.status === "offen" && <Pill>offen</Pill>}
</button>
}
@@ -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<FahrtFelderEingabe>(LEERE_FAHRT_FELDER)
const [fehler, setzeFehler] = useState<string | null>(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)}
/>
</Feld>
<Feld label="Art" last>
<select
className="dm-auswahl"
value={art}
onChange={(e) => setzeArt(e.target.value as "privat" | "arbeitsweg")}
>
<option value="privat">Privat</option>
<option value="arbeitsweg">Arbeitsweg</option>
</select>
</Feld>
<FahrtFelderFormular werte={felder} setzeWerte={setzeFelder} />
{fehler && (
<p className="dm-fehler" role="alert">
{fehler}
+8 -1
View File
@@ -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 && (
<Wertzeile
label="Eigene Prognose (Inspektion)"
wert={datum(inspektionsprognose.datum)}
/>
)}
<Wertzeile
label="Hauptuntersuchung"
wert={fahrzeug.hu ? datum(fahrzeug.hu) : "—"}
+23 -1
View File
@@ -4,7 +4,7 @@
* greifen, statt dass eine leere Liste die Seite zerlegt.
*/
import { render, screen, waitFor } from "@testing-library/react"
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import { beforeEach, describe, expect, it } from "vitest"
import { DatenAnbieter } from "../daten/DatenKontext"
@@ -106,4 +106,26 @@ describe("Inhalte kommen wirklich aus den Daten", () => {
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")
})
})
-1
View File
@@ -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: {
+11 -16
View File
@@ -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",