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 <[email protected]>
This commit is contained in:
2026-08-17 12:16:40 +02:00
co-authored by Claude Opus 5
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 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 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 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 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 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. 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/`; 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, console showed only the pre-existing service-worker/404 noise from the missing vehicle photos,
nothing new. nothing new.
- [ ] **New gap found while auditing "what's needed for a real iPhone app" (2026-08-17):** - [x] **Gap found while auditing "what's needed for a real iPhone app" (2026-08-17), then closed
`companion-app/src/screens/` has not been touched since 2026-08-11 (`git log -1` on that the same day:** `companion-app/src/screens/` hadn't been touched since 2026-08-11 — every
path) — every panel fix and feature from the six days of sessions since (the full HIG audit panel fix and feature from the six days since (HIG audit rounds, receipt upload, trip
rounds, receipt drag/paste upload, trip editing, Ankunftszeit, Inspektion forecast, editing, Inspektion forecast, ...) existed only in `homeassistant/www/audi-dashboard-app.js`.
Heckklappenschloss removal, the Tag-theme placeholder fix, all of it) exists **only** in User decision: port everything now, before any native build work (Android build itself
`homeassistant/www/audi-dashboard-app.js`, not in the companion app. The two codebases are deprioritized/uncertain per the user - Paul has an Apple Developer Program and will help with
independent implementations of the same product, not a shared component — nothing in this iOS signing). Audited both codebases field-by-field (not by diffing the panel's CSS/markup,
repo currently keeps them in sync automatically. Whoever next works on the companion app which doesn't transfer to `@audi-dash/ui` components) and found six real, mechanically
needs to either treat this as a deliberate "port everything since 2026-08-11" task, or distinct gaps, all fixed:
explicitly decide the divergence is acceptable for now. Not scoped or started. 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) — - [ ] 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 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` 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 ## 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 - QR-Einrichtung als Alternative zum Einfügen des Tokens
- Live-Ansicht der laufenden Fahrt: gebaut, aber über - Live-Ansicht der laufenden Fahrt: gebaut, aber über
`src/funktionen.ts` abgeschaltet, bis der FMM003 echte Werte liefert `src/funktionen.ts` abgeschaltet, bis der FMM003 echte Werte liefert
+28 -2
View File
@@ -33,6 +33,21 @@ export interface TankvorgangFelder {
kraftstoff?: string | null; 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 /** Bündelt die drei Bausteine und bildet die Fachvorgänge ab, die das alte
Dashboard über hass.callService erledigt hat. */ Dashboard über hass.callService erledigt hat. */
export class DataMetricApi { export class DataMetricApi {
@@ -94,15 +109,26 @@ export class DataMetricApi {
} }
/** Legt eine Fahrt von Hand an — für Fahrten ohne automatische Erkennung. */ /** 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( return this.warteschlange.einreihen(
"pyscript", "pyscript",
"audi_dashboard_fahrt_manuell_anlegen", "audi_dashboard_fahrt_manuell_anlegen",
{ ts_start: tsStart, ts_end: tsEnde, art }, { ...felder },
"Fahrt eintragen", "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> { fahrtLoeschen(tripId: string): Promise<unknown> {
return this.warteschlange.einreihen( return this.warteschlange.einreihen(
"pyscript", "pyscript",
@@ -19,7 +19,6 @@ function beispielprofil(aenderung: Partial<Profil> = {}): Profil {
untertitel: "quattro · 331 kW", untertitel: "quattro · 331 kW",
kennzeichen: "M AB 1234", kennzeichen: "M AB 1234",
tankvolumen_liter: 58, tankvolumen_liter: 58,
wlan_name: "Audi_MMI",
}, },
einstellungen: { einstellungen: {
uebersichtsbild: "seitenansicht.webp", uebersichtsbild: "seitenansicht.webp",
-3
View File
@@ -21,7 +21,6 @@ export interface Einstellungen {
nachtBis: string nachtBis: string
pauseMin: number pauseMin: number
markenname: string markenname: string
wlanName: string
backupIntervall: string backupIntervall: string
letztesBackup: string | null letztesBackup: string | null
ausfuehrung: string ausfuehrung: string
@@ -114,7 +113,6 @@ export function profilZuEinstellungen(profil: Profil): Einstellungen {
nachtBis: text(einst, "nacht_bis", "06:00"), nachtBis: text(einst, "nacht_bis", "06:00"),
pauseMin: zahl(einst, "fahrten_pausenzeit_min") ?? 15, pauseMin: zahl(einst, "fahrten_pausenzeit_min") ?? 15,
markenname: text(einst, "kraftstoffanbieter", "Shell"), markenname: text(einst, "kraftstoffanbieter", "Shell"),
wlanName: text(fahrzeug, "wlan_name"),
backupIntervall: text(einst, "backup_intervall", "aus"), backupIntervall: text(einst, "backup_intervall", "aus"),
letztesBackup: text(einst, "letztes_backup") || null, letztesBackup: text(einst, "letztes_backup") || null,
ausfuehrung: text(fahrzeug, "ausfuehrung"), ausfuehrung: text(fahrzeug, "ausfuehrung"),
@@ -199,7 +197,6 @@ export function zusammenfuehren(
f["zusatz"] = einstellungen.fahrzeugzusatz f["zusatz"] = einstellungen.fahrzeugzusatz
f["untertitel"] = einstellungen.fahrzeuguntertitel f["untertitel"] = einstellungen.fahrzeuguntertitel
f["kennzeichen"] = einstellungen.kennzeichen f["kennzeichen"] = einstellungen.kennzeichen
f["wlan_name"] = einstellungen.wlanName
f["ausfuehrung"] = einstellungen.ausfuehrung f["ausfuehrung"] = einstellungen.ausfuehrung
f["tankvolumen_liter"] = einstellungen.tankvolumen f["tankvolumen_liter"] = einstellungen.tankvolumen
f["fin"] = fahrzeug.fin f["fin"] = fahrzeug.fin
+21 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest" import { describe, expect, it } from "vitest"
import { letzterOelwechsel, oelwechselPrognose } from "./service" import { inspektionPrognose, letzterOelwechsel, oelwechselPrognose } from "./service"
import type { Fahrzeug } from "./profilAdapter" import type { Fahrzeug } from "./profilAdapter"
function fahrzeug(odo: number, modus = "hersteller"): Fahrzeug { function fahrzeug(odo: number, modus = "hersteller"): Fahrzeug {
@@ -92,3 +92,23 @@ describe("oelwechselPrognose", () => {
expect(oelwechselPrognose(fahrzeug(46000), [], jetzt)).toBeNull() 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 * Eigene Service-Prognosen (Ölwechsel, Inspektion). Portiert aus
* Panel. * `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 * Die Rechnung beginnt bewusst neu ab dem letzten tatsächlich durchgeführten
* Ölwechsel der allgemeine Langzeitdurchschnitt seit dem allerersten * Eintrag dieser Art der allgemeine Langzeitdurchschnitt seit dem
* Servicebucheintrag wäre dafür zu träge. Aus km und Tagen seit dem letzten * allerersten Servicebucheintrag wäre dafür zu träge. Aus km und Tagen seit
* Wechsel ergibt sich die aktuelle Fahrleistung pro Tag; hochgerechnet auf die * dem letzten Eintrag ergibt sich die aktuelle Fahrleistung pro Tag;
* verbleibenden km bis zum eingestellten Intervall ergibt das ein Datum, * hochgerechnet auf die verbleibenden km bis zum Intervall ergibt das ein
* gedeckelt auf spätestens das Zeitlimit des Intervalls. * Datum, gedeckelt auf spätestens das Zeitlimit des Intervalls.
*/ */
import type { Fahrzeug } from "./profilAdapter" import type { Fahrzeug } from "./profilAdapter"
@@ -36,33 +37,31 @@ function monatePlus(isoDatum: string, monate: number): string {
return d.toISOString().slice(0, 10) return d.toISOString().slice(0, 10)
} }
/** Der jüngste Servicebucheintrag, dessen Art auf einen Ölwechsel hindeutet. */ /** Der jüngste Servicebucheintrag, dessen Art auf das gesuchte Stichwort
export function letzterOelwechsel(buch: readonly Buchhaltung[]): Buchhaltung | null { passt (z. B. "öl"/"oel" oder "inspektion"). */
function letzterEintrag(buch: readonly Buchhaltung[], stichwort: RegExp): Buchhaltung | null {
const treffer = buch 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))) .sort((a, b) => String(b.datum).localeCompare(String(a.datum)))
return treffer[0] ?? null 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, fahrzeug: Fahrzeug,
buch: readonly Buchhaltung[], letzter: Buchhaltung | null,
jetzt = new Date(), intervallKm: number,
intervallMonate: number,
jetzt: Date,
): Oelprognose | null { ): Oelprognose | null {
if (!fahrzeug.odoBekannt) return null if (!fahrzeug.odoBekannt) return null
const letzter = letzterOelwechsel(buch)
if (!letzter?.datum || letzter.km == null) return null 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 if (!Number.isFinite(intervallKm) || intervallKm <= 0) return null
const zielKm = letzter.km + intervallKm const zielKm = letzter.km + intervallKm
@@ -76,7 +75,7 @@ export function oelwechselPrognose(
const tageSeit = (jetzt.getTime() - new Date(letzter.datum).getTime()) / TAG_MS const tageSeit = (jetzt.getTime() - new Date(letzter.datum).getTime()) / TAG_MS
const kmSeit = fahrzeug.odo - letzter.km 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. // seriöse Kilometerprognose — dann gilt das Zeitlimit.
if (tageSeit <= 0 || kmSeit <= 0) { if (tageSeit <= 0 || kmSeit <= 0) {
return { datum: kappDatum, restKm, kmProTag: 0, durchZeitlimit: true } return { datum: kappDatum, restKm, kmProTag: 0, durchZeitlimit: true }
@@ -94,3 +93,30 @@ export function oelwechselPrognose(
durchZeitlimit, 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**- * Wichtig für die Einordnung: Der Ladezustand wird aus der **Tagesminimum**-
* Spannung geschätzt (Ruhespannung kurz nach dem Start, bevor die Lichtmaschine * 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. * taugen nicht als Ruhewert.
*/ */
@@ -28,8 +28,9 @@ interface Tageswert {
max_ts?: string max_ts?: string
} }
/** Ab hier gilt eine Messung als nicht mehr in Ruhe aufgenommen. */ /** Ab hier gilt eine Messung als nicht mehr in Ruhe aufgenommen - alles
const AGM_RUHE_MAX_V = 13.2 darüber ist schon Lichtmaschinenspannung, nicht mehr Ruhespannung. */
const AGM_RUHE_MAX_V = 12.8
/** Spannung → Ladezustand für eine AGM-Batterie in Ruhe. */ /** Spannung → Ladezustand für eine AGM-Batterie in Ruhe. */
const LADEKURVE: [number, number][] = [ const LADEKURVE: [number, number][] = [
+1 -8
View File
@@ -102,7 +102,7 @@ export function Einstellungen({
onChange={(e) => aendern({ kennzeichen: e.target.value })} onChange={(e) => aendern({ kennzeichen: e.target.value })}
/> />
</Feld> </Feld>
<Feld label="Tankvolumen" unit="l"> <Feld label="Tankvolumen" unit="l" last>
<input <input
className="dm-eingabe" className="dm-eingabe"
inputMode="numeric" inputMode="numeric"
@@ -110,13 +110,6 @@ export function Einstellungen({
onChange={(e) => aendern({ tankvolumen: Number(e.target.value) || 0 })} onChange={(e) => aendern({ tankvolumen: Number(e.target.value) || 0 })}
/> />
</Feld> </Feld>
<Feld label="WLAN des Fahrzeugs" last>
<input
className="dm-eingabe"
value={werte.wlanName}
onChange={(e) => aendern({ wlanName: e.target.value })}
/>
</Feld>
</Tile> </Tile>
<Tile> <Tile>
+98 -2
View File
@@ -9,16 +9,29 @@
* ist, statt eine glaubwürdig aussehende Erfindung zu zeigen. * 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 { useDaten } from "../daten/DatenKontext"
import { datum, dauer, de, deOderStrich, uhrzeit } from "../format" import { datum, dauer, de, deOderStrich, uhrzeit } from "../format"
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine" import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
import { FahrtFelderFormular, fahrtFelderAusFahrt, fahrtFelderAuswerten } from "./FahrtFelder"
import { Karte } from "./Karte" 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 }) { export function FahrtDetail({ id }: { id: string | undefined }) {
const { fahrten } = useDaten() const { fahrten } = useDaten()
const fahrt = fahrten.find((f) => f.trip_id === id) const fahrt = fahrten.find((f) => f.trip_id === id)
const [bearbeitenOffen, setzeBearbeitenOffen] = useState(false)
if (!fahrt) { if (!fahrt) {
return ( 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 const hatPositionen = fahrt.start_lat != null && fahrt.start_lon != null
return ( return (
<> <>
<Tile> <Tile>
<div className="dm-abschnitt" style={{ marginBottom: 0 }}>
<span className="ads-eyebrow"> <span className="ads-eyebrow">
{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
</span> </span>
<button type="button" className="dm-textknopf" onClick={() => setzeBearbeitenOffen(true)}>
Bearbeiten
</button>
</div>
<div className="dm-detail__kopf"> <div className="dm-detail__kopf">
<div className="dm-detail__zahl"> <div className="dm-detail__zahl">
{deOderStrich(fahrt.distance_km, 1)} {deOderStrich(fahrt.distance_km, 1)}
<span className="dm-detail__einheit">km</span> <span className="dm-detail__einheit">km</span>
</div> </div>
<div className="dm-detail__marken"> <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.status === "offen" && <Pill>noch offen</Pill>}
{fahrt.source === "manual" && <Pill>selbst eingetragen</Pill>} {fahrt.source === "manual" && <Pill>selbst eingetragen</Pill>}
</div> </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 { datum, dauer, de, deOderStrich, monatJahr, summe, uhrzeit } from "../format"
import type { SeitenName } from "../navigation" import type { SeitenName } from "../navigation"
import { Leerzustand } from "./bausteine" import { Leerzustand } from "./bausteine"
import { FahrtFelderFormular, LEERE_FAHRT_FELDER, fahrtFelderAuswerten } from "./FahrtFelder"
import type { FahrtFelderEingabe } from "./FahrtFelder"
import { Zeilenmenue } from "./Zeilenmenue" import { Zeilenmenue } from "./Zeilenmenue"
export function Fahrten({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) { 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)} {dauer(fahrt.duration_s)}
</span> </span>
</span> </span>
{fahrt.art === "arbeitsweg" && <Pill variant="work">Arbeitsweg</Pill>} {fahrt.art === "arbeitsweg" && <Pill>Arbeitsweg</Pill>}
{fahrt.status === "offen" && <Pill>offen</Pill>} {fahrt.status === "offen" && <Pill>offen</Pill>}
</button> </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 }) { function FahrtFormular({ beiFertig }: { beiFertig: () => void }) {
const { api } = useDaten() const { api } = useDaten()
const [start, setzeStart] = useState("") const [start, setzeStart] = useState("")
const [ende, setzeEnde] = 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 [fehler, setzeFehler] = useState<string | null>(null)
const [laeuft, setzeLaeuft] = useState(false) const [laeuft, setzeLaeuft] = useState(false)
@@ -117,7 +122,11 @@ function FahrtFormular({ beiFertig }: { beiFertig: () => void }) {
} }
setzeLaeuft(true) setzeLaeuft(true)
try { 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() beiFertig()
} catch (ursache) { } catch (ursache) {
setzeFehler(ursache instanceof Error ? ursache.message : String(ursache)) setzeFehler(ursache instanceof Error ? ursache.message : String(ursache))
@@ -145,16 +154,7 @@ function FahrtFormular({ beiFertig }: { beiFertig: () => void }) {
onChange={(e) => setzeEnde(e.target.value)} onChange={(e) => setzeEnde(e.target.value)}
/> />
</Feld> </Feld>
<Feld label="Art" last> <FahrtFelderFormular werte={felder} setzeWerte={setzeFelder} />
<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>
{fehler && ( {fehler && (
<p className="dm-fehler" role="alert"> <p className="dm-fehler" role="alert">
{fehler} {fehler}
+8 -1
View File
@@ -13,7 +13,7 @@ import { useState } from "react"
import { ActionButton, Feld, Tile } from "@audi-dash/ui" import { ActionButton, Feld, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext" import { useDaten } from "../daten/DatenKontext"
import { oelwechselPrognose } from "../daten/service" import { inspektionPrognose, oelwechselPrognose } from "../daten/service"
import { datum, de, isoTag } from "../format" import { datum, de, isoTag } from "../format"
import type { SeitenName } from "../navigation" import type { SeitenName } from "../navigation"
import { Leerzustand, NaviKachel, Wertzeile, Werteliste, bestaetigen } from "./bausteine" 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 buch = (fahrzeug.service["buch"] ?? []) as ServicebuchEintrag[]
const autohaus = (fahrzeug.service["autohaus"] ?? {}) as Autohaus const autohaus = (fahrzeug.service["autohaus"] ?? {}) as Autohaus
const prognose = oelwechselPrognose(fahrzeug, buch) const prognose = oelwechselPrognose(fahrzeug, buch)
const inspektionsprognose = inspektionPrognose(fahrzeug, buch)
return ( return (
<> <>
@@ -77,6 +78,12 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
: undefined : undefined
} }
/> />
{inspektionsprognose && (
<Wertzeile
label="Eigene Prognose (Inspektion)"
wert={datum(inspektionsprognose.datum)}
/>
)}
<Wertzeile <Wertzeile
label="Hauptuntersuchung" label="Hauptuntersuchung"
wert={fahrzeug.hu ? datum(fahrzeug.hu) : "—"} wert={fahrzeug.hu ? datum(fahrzeug.hu) : "—"}
+23 -1
View File
@@ -4,7 +4,7 @@
* greifen, statt dass eine leere Liste die Seite zerlegt. * 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 { beforeEach, describe, expect, it } from "vitest"
import { DatenAnbieter } from "../daten/DatenKontext" import { DatenAnbieter } from "../daten/DatenKontext"
@@ -106,4 +106,26 @@ describe("Inhalte kommen wirklich aus den Daten", () => {
await waitFor(() => expect(screen.getByText("48,70")).toBeTruthy()) await waitFor(() => expect(screen.getByText("48,70")).toBeTruthy())
expect(screen.getByText("1,83 €")).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", erstzulassung: "2023-04-01",
hauptuntersuchung_faellig: "2027-04-01", hauptuntersuchung_faellig: "2027-04-01",
tankvolumen_liter: 58, tankvolumen_liter: 58,
wlan_name: "Audi_MMI",
ausfuehrung: "competition", ausfuehrung: "competition",
}, },
einstellungen: { einstellungen: {
+11 -16
View File
@@ -157,7 +157,6 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.7", "@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7", "@babel/generator": "^7.29.7",
@@ -512,7 +511,6 @@
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.5.0.tgz", "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.5.0.tgz",
"integrity": "sha512-Ca4krtqH1hothjtBIwf2J2TW7IhYq1ujp8QeItTiJohNsqij8ja2DYYH3DU0l8RmxCWaBAFTGA2TgOgOMCSNsQ==", "integrity": "sha512-Ca4krtqH1hothjtBIwf2J2TW7IhYq1ujp8QeItTiJohNsqij8ja2DYYH3DU0l8RmxCWaBAFTGA2TgOgOMCSNsQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"tslib": "^2.1.0" "tslib": "^2.1.0"
} }
@@ -623,7 +621,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=20.19.0" "node": ">=20.19.0"
}, },
@@ -672,7 +669,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=20.19.0" "node": ">=20.19.0"
} }
@@ -1776,7 +1772,8 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
@@ -2062,6 +2059,7 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@@ -2082,6 +2080,7 @@
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"dequal": "^2.0.3" "dequal": "^2.0.3"
} }
@@ -2236,7 +2235,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.11.12", "baseline-browser-mapping": "^2.11.12",
"caniuse-lite": "^1.0.30001809", "caniuse-lite": "^1.0.30001809",
@@ -2520,6 +2518,7 @@
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@@ -2529,7 +2528,8 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.404", "version": "1.5.404",
@@ -2595,7 +2595,6 @@
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"esbuild": "bin/esbuild" "esbuild": "bin/esbuild"
}, },
@@ -3036,6 +3035,7 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"lz-string": "bin/bin.js" "lz-string": "bin/bin.js"
} }
@@ -3305,7 +3305,6 @@
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -3370,7 +3369,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.17", "nanoid": "^3.3.17",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -3429,6 +3427,7 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1", "ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0", "ansi-styles": "^5.0.0",
@@ -3477,7 +3476,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@@ -3490,7 +3488,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
@@ -3504,7 +3501,8 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
@@ -4165,7 +4163,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -4273,7 +4270,6 @@
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4", "fdir": "^6.4.4",
@@ -5883,7 +5879,6 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.43", "postcss": "^8.4.43",