diff --git a/AGENTS.md b/AGENTS.md index 00f1f93..ee20603 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,8 +12,9 @@ und ohne Netz rendert; dazu der QR-Code aus einem gespeicherten Bild, ein stille Debug-Knopf und der HA-Zugang bei den Zugaengen; der Cache-Brecher der Bilder haengt jetzt am Foto statt am App-Start; dazu ein Audit mit zwei Befunden in der Wisch-Zeile und der Auswahlliste der App; der Leerzustand der Karte ist deckend, -`2026.9.4.16`, -Abschnitte CC bis CJ. Davor: Geraetezeit statt Ankunftszeit - die Wurzel hinter der 3-km-Fahrt: +dazu die Rueckfrage vor loeschenden Aktionen als eigenes Blatt statt als +Browserdialog, `2026.9.4.17`, +Abschnitte CC bis CK. Davor: Geraetezeit statt Ankunftszeit - die Wurzel hinter der 3-km-Fahrt: das Fahrtfenster stand in Geraetezeit, der Verlauf war nach Ankunftszeit sortiert. Dazu die neun Paritaetsbefunde und die verunreinigte Batteriehistorie, `2026.9.4.1`, Abschnitt CB. Davor: Der Regler stellt den Schlaf-Timeout des Dongles, flespi lesend und schreibend, die eigene Warteschlange wieder entfallen, `2026.9.3.10`, Abschnitt BZ. Davor: Zugaenge: die Dienste-Token aus App und Panel bedienbar, der Wert kommt nie @@ -10312,3 +10313,53 @@ Verbesserung (eigene Gestaltung statt Systemdialog, funktioniert in jedem Kontext, Gleichlauf mit dem Panel), kostet aber acht Aufrufstellen den Umbau von synchron auf einen Zustand je Stelle - deshalb hier vermerkt statt nebenbei gemacht. + +## CK. Die Rueckfrage ist ein eigenes Blatt, kein Browserdialog mehr (2026.9.4.17) + +Ausloeser war ein Fehlerbericht, der keiner war: „Verbindung trennen tut +nichts" (App unter `:5173`). Gemessen: `window.confirm` kehrt im +Browser-Bereich sofort mit `false` zurueck (1 ms) - und `abmelden()` beginnt +mit genau dieser Rueckfrage. Auf dem Telefon zeigt Capacitor dafuer einen +Systemdialog, dort greift der Knopf. + +Die Suche danach brachte den eigentlichen Befund: **elf** Stellen der App +hingen an `window.confirm` - acht direkt, drei weitere im `Zeilenmenue`, das +die Wischgeste in Fahrten, Tankvorgaengen und Messwerten traegt. Jede davon +ist eine loeschende Aktion, und jede lief in eingebetteten Ansichten still ins +Leere. Das Panel zeigt an denselben Stellen seit jeher sein eigenes Blatt. + +### `screens/bestaetigung.tsx` + +Ein winziger Speicher plus ein `ActionSheet`, das **einmal** in `App.tsx` +haengt (und in der Musterseite). `bestaetigen()` bleibt fuer die Aufrufstellen +fast unveraendert - nur mit `await` davor. + +**Warum kein Hook je Aufrufstelle:** ein Hook haette jede der elf Stellen +gezwungen, das Blatt auch selbst zu rendern - elf Gelegenheiten, es zu +vergessen, ohne dass etwas auffaellt (der Aufruf wuerde einfach nie +antworten). Dasselbe Muster wie `theme.ts`: Zustand ausserhalb des +Komponentenbaums, gelesen ueber `useSyncExternalStore`. + +**Der Rueckfall ist Absicht:** ist das Blatt nirgends gehaengt (Testlauf, +Fremdeinbettung), gilt wieder `window.confirm`. Eine Rueckfrage, die niemand +beantworten kann, waere schlechter als der Systemdialog. + +**Wortgleich mit dem Panel:** `LOESCH_TEXT` und `LOESCH_HINWEIS` sind von dort +uebernommen, samt der Trennung in Frage, erklaerenden Satz und +Knopfbeschriftung - `window.confirm` kannte nur einen Text. „Neue Raeder +anlegen?" ist die einzige Rueckfrage, die **nicht** rot ist: sie loescht +nichts, sie archiviert. + +### Verifiziert + +`tsc --noEmit` sauber, **225 Tests** (vier neue in `bestaetigung.test.tsx`: +Ja-Antwort, Abbrechen samt Schliessen, eigene Knopfbeschriftung ohne roten +Knopf, und der Rueckfall auf den Systemdialog). + +**Live in der App**, an der Stelle des Fehlerberichts: der Knopf zeigt jetzt +das Blatt mit „Zugang von diesem Geraet entfernen?", dem erklaerenden Satz, +rotem „Trennen" und abgesetztem „Abbrechen". **Bestaetigt wurde bewusst +nicht** - das haette den eingefuegten Token des Eigentuemers geloescht; +Abbrechen schliesst das Blatt, die Sitzung bleibt. + +Kein `window.confirm` mehr im Quelltext ausser dem dokumentierten Rueckfall. diff --git a/companion-app/muster/muster.tsx b/companion-app/muster/muster.tsx index 5f4b48e..408ce18 100644 --- a/companion-app/muster/muster.tsx +++ b/companion-app/muster/muster.tsx @@ -38,6 +38,7 @@ import { DatenAnbieter } from "../src/daten/DatenKontext" import { Shell } from "../src/Shell" import type { SeitenName } from "../src/navigation" import { TITEL } from "../src/navigation" +import { Bestaetigungsblatt } from "../src/screens/bestaetigung" import { Einrichtung } from "../src/screens/Einrichtung" import { SeiteFuer } from "../src/screens/register" import { beispielApi } from "../src/tests/beispieldaten" @@ -52,6 +53,7 @@ function Muster() { if (gewaehlt === "einrichtung") return {}} /> return ( + + {/* Einmal fuer die ganze App: die Rueckfrage vor loeschenden Aktionen + (siehe screens/bestaetigung.tsx). Rendert nichts, solange nichts + gefragt ist. */} + {start === "pruefen" &&
Einen Moment …
} {start === "einrichten" && setzeStart("bereit")} />} {start === "bereit" && ( diff --git a/companion-app/src/screens/BatterieListe.tsx b/companion-app/src/screens/BatterieListe.tsx index afd7d65..12c7a12 100644 --- a/companion-app/src/screens/BatterieListe.tsx +++ b/companion-app/src/screens/BatterieListe.tsx @@ -22,6 +22,7 @@ import { useDaten } from "../daten/DatenKontext" import { datum, datumUndZeit, de } from "../format" import { Leerzustand } from "./bausteine" import { AGM_RUHE_MAX_V } from "./Batterie" +import { LOESCH_HINWEIS } from "./bestaetigung" import { Zeilenmenue } from "./Zeilenmenue" interface Tageswert { @@ -79,6 +80,7 @@ export function BatterieListe() { void loeschen(t)} kinder={
diff --git a/companion-app/src/screens/Einstellungen.tsx b/companion-app/src/screens/Einstellungen.tsx index c163a87..caa41ba 100644 --- a/companion-app/src/screens/Einstellungen.tsx +++ b/companion-app/src/screens/Einstellungen.tsx @@ -17,7 +17,8 @@ import { OtaFehler, buendelAnwenden, buendelPasst, otaMoeglich } from "../daten/ import type { Einstellungen as EinstellungenWerte } from "../daten/profilAdapter" import { datum, datumZeit, de, isoTag, standAlter } from "../format" import { useTheme } from "../theme" -import { BildMitMenue, Wertzeile, Werteliste, bestaetigen } from "./bausteine" +import { BildMitMenue, Wertzeile, Werteliste } from "./bausteine" +import { bestaetigen } from "./bestaetigung" import { BILDPLAETZE, SEITE_WINTER, markenlogoUrl } from "./bilder" import { DatensatzPopup } from "./DatensatzPopup" import { HistorienImport } from "./HistorienImport" @@ -287,7 +288,13 @@ export function Einstellungen({ } const abmelden = async () => { - if (!bestaetigen("Zugang von diesem Gerät entfernen? Die Daten auf dem Server bleiben.")) return + if ( + !(await bestaetigen("Zugang von diesem Gerät entfernen?", { + text: "Adresse und Token werden von diesem Gerät gelöscht. Die Daten auf dem Server bleiben unberührt.", + knopf: "Trennen", + })) + ) + return await zugangVerwerfen() zugangVergessen() beiAbmeldung() @@ -929,9 +936,15 @@ export function Einstellungen({ { - if (bestaetigen("Token löschen?")) { - void tokenSchreiben(z.dienst, "") - } + void (async () => { + if ( + !(await bestaetigen("Token löschen?", { + text: "Der hinterlegte Token wird entfernt. Bis ein neuer eingetragen ist, kann die Integration diesen Dienst nicht mehr erreichen.", + })) + ) + return + await tokenSchreiben(z.dienst, "") + })() }} > Löschen diff --git a/companion-app/src/screens/FahrtDetail.tsx b/companion-app/src/screens/FahrtDetail.tsx index b9b895d..a80f0c8 100644 --- a/companion-app/src/screens/FahrtDetail.tsx +++ b/companion-app/src/screens/FahrtDetail.tsx @@ -17,7 +17,8 @@ import type { Fahrt } from "../api" import { useDaten } from "../daten/DatenKontext" import { datum, dauer, de, streckeTeile, uhrzeit } from "../format" import type { SeitenName } from "../navigation" -import { Leerzustand, Wertzeile, Werteliste, bestaetigen } from "./bausteine" +import { Leerzustand, Wertzeile, Werteliste } from "./bausteine" +import { LOESCH_HINWEIS, LOESCH_TEXT, bestaetigen } from "./bestaetigung" import { FahrtFelderFormular, fahrtFelderAusFahrt, fahrtFelderAuswerten } from "./FahrtFelder" import { Karte } from "./Karte" @@ -83,8 +84,9 @@ export function FahrtDetail({ id, geheZu }: { id: string | undefined; geheZu: (n void api.fahrtAktualisieren(fahrt.trip_id, { art: neu }).then(() => jetztAktualisieren()) } - const loeschen = () => { - if (!fahrt || !bestaetigen("Fahrt löschen?")) return + const loeschen = async () => { + if (!fahrt) return + if (!(await bestaetigen(LOESCH_TEXT.trip, { text: LOESCH_HINWEIS.trip }))) return void api.fahrtLoeschen(fahrt.trip_id) geheZu("trips") } diff --git a/companion-app/src/screens/Fahrten.tsx b/companion-app/src/screens/Fahrten.tsx index 8aec2d3..36bc932 100644 --- a/companion-app/src/screens/Fahrten.tsx +++ b/companion-app/src/screens/Fahrten.tsx @@ -14,6 +14,7 @@ import type { SeitenName } from "../navigation" import { Blattzeile, Leerzustand } from "./bausteine" import { FahrtFelderFormular, LEERE_FAHRT_FELDER, fahrtFelderAuswerten } from "./FahrtFelder" import type { FahrtFelderEingabe } from "./FahrtFelder" +import { LOESCH_HINWEIS } from "./bestaetigung" import { Zeilenmenue } from "./Zeilenmenue" /** Vorlage: strecke() im Panel - "Start → Ziel", nur eine Seite, oder @@ -103,6 +104,7 @@ export function Fahrten({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void api.fahrtLoeschen(fahrt.trip_id)} kinder={ { - if (!bestaetigen("Neue Räder anlegen?")) return + if ( + !(await bestaetigen("Neue Räder anlegen?", { + text: "Der jetzige Satz wandert mit seiner Laufleistung ins Archiv, der laufende beginnt bei 0 km.", + knopf: "Anlegen", + destruktiv: false, + })) + ) + return setzeLaeuft(true) try { // Eigener Dienst wie reifen_wechseln: archiviert den bisherigen Stand @@ -369,7 +377,7 @@ function ArchivZeile({ eintrag }: { eintrag: ReifenArchivEintrag }) { } const loeschen = async () => { - if (!bestaetigen("Diesen archivierten Reifensatz löschen?")) return + if (!(await bestaetigen(LOESCH_TEXT.rfarch, { text: LOESCH_HINWEIS.rfarch }))) return setzeLaeuft(true) try { await api.reifenArchivLoeschen(eintrag.id) diff --git a/companion-app/src/screens/Service.tsx b/companion-app/src/screens/Service.tsx index cf0c125..b5b90d1 100644 --- a/companion-app/src/screens/Service.tsx +++ b/companion-app/src/screens/Service.tsx @@ -30,8 +30,8 @@ import { Blattzeile, Leerzustand, Zahnradknopf, - bestaetigen, } from "./bausteine" +import { LOESCH_HINWEIS, LOESCH_TEXT, bestaetigen } from "./bestaetigung" import { Datumsfeld } from "./Datumsfeld" import { terminUebernehmen } from "./kalender" import { vcardParsen } from "./vcard" @@ -487,7 +487,7 @@ export function Servicebuch({ } const loeschen = async () => { - if (!bestaetigen("Diesen Wartungsplan-Eintrag löschen?")) return + if (!(await bestaetigen(LOESCH_TEXT.sbuch, { text: LOESCH_HINWEIS.sbuch }))) return await schreiben(buchRoh.filter((_, i) => i !== index)) geheZu("service") } diff --git a/companion-app/src/screens/TankDetail.tsx b/companion-app/src/screens/TankDetail.tsx index 1d3d0cf..e89f458 100644 --- a/companion-app/src/screens/TankDetail.tsx +++ b/companion-app/src/screens/TankDetail.tsx @@ -17,7 +17,8 @@ import { useDaten } from "../daten/DatenKontext" import { kartendienstUrl, tankstelleZeilen } from "../daten/tankstelle" import { datum, de, deOderStrich, eur, uhrzeit } from "../format" import type { SeitenName } from "../navigation" -import { KRAFTSTOFFSORTEN, Leerzustand, Wertzeile, Werteliste, bestaetigen } from "./bausteine" +import { KRAFTSTOFFSORTEN, Leerzustand, Wertzeile, Werteliste } from "./bausteine" +import { LOESCH_HINWEIS, LOESCH_TEXT, bestaetigen } from "./bestaetigung" import { markenlogoUrl } from "./bilder" import { BelegPopup } from "./BelegPopup" import { TankstellenKarte } from "./TankstellenKarte" @@ -44,8 +45,9 @@ export function TankDetail({ id, geheZu }: { id: string | undefined; geheZu: (na ) } - const loeschen = () => { - if (!tank || !bestaetigen("Tankvorgang löschen?")) return + const loeschen = async () => { + if (!tank) return + if (!(await bestaetigen(LOESCH_TEXT.fill, { text: LOESCH_HINWEIS.fill }))) return void api.tankvorgangLoeschen(tank.tank_id) geheZu("fuel") } diff --git a/companion-app/src/screens/Tanken.tsx b/companion-app/src/screens/Tanken.tsx index d9e5d70..ab4696a 100644 --- a/companion-app/src/screens/Tanken.tsx +++ b/companion-app/src/screens/Tanken.tsx @@ -24,6 +24,7 @@ import { import { Blattzeile, KRAFTSTOFFSORTEN, Leerzustand } from "./bausteine" import { BelegPopup } from "./BelegPopup" import { markenlogoUrl } from "./bilder" +import { LOESCH_HINWEIS } from "./bestaetigung" import { Zeilenmenue } from "./Zeilenmenue" export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) { @@ -118,6 +119,7 @@ export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void api.tankvorgangLoeschen(tank.tank_id)} kinder={ void loeschFrage: string + /** Der erklaerende Satz unter der Frage - wie LOESCH_HINWEIS im Panel. */ + loeschHinweis?: string kinder: ReactNode }) { + // Eigenes Blatt statt window.confirm: in eingebetteten Ansichten sind + // Dialoge abgeschaltet, dort lief das Loeschen still ins Leere. Siehe + // bestaetigung.tsx. const loeschen = () => { - if (window.confirm(loeschFrage)) beiLoeschen() + void (async () => { + if (await bestaetigen(loeschFrage, loeschHinweis ? { text: loeschHinweis } : {})) + beiLoeschen() + })() } return ( diff --git a/companion-app/src/screens/bausteine.tsx b/companion-app/src/screens/bausteine.tsx index f385794..6811a6b 100644 --- a/companion-app/src/screens/bausteine.tsx +++ b/companion-app/src/screens/bausteine.tsx @@ -12,6 +12,7 @@ import { useDaten } from "../daten/DatenKontext" import { SymbolZahnradVoll } from "../symbole" import { Bild } from "./Bild" import { bildAlsBase64, bildVerkleinern, bildVersionErneuern } from "./bilder" +import { LOESCH_HINWEIS, LOESCH_TEXT, bestaetigen } from "./bestaetigung" /** Kraftstoffsorten zur Auswahl - dieselbe feste Liste wie `KRAFTSTOFFSORTEN` im Panel, geteilt zwischen Tanken.tsx (Neuanlegen) und TankDetail.tsx @@ -163,11 +164,13 @@ export function BildMitMenue({ destructive: true, onClick: () => { setzeOffen(false) - if (!bestaetigen("Foto löschen?")) return - void api.bildLoeschen(datei).then(() => { + void (async () => { + if (!(await bestaetigen(LOESCH_TEXT.bild, { text: LOESCH_HINWEIS.bild }))) + return + await api.bildLoeschen(datei) bildVersionErneuern() - return jetztAktualisieren() - }) + await jetztAktualisieren() + })() }, }, ]} @@ -291,10 +294,6 @@ export function Werteliste({ kinder }: { kinder: ReactNode }) { } /** Bestätigung vor unwiderruflichen Aktionen. */ -export function bestaetigen(frage: string): boolean { - return window.confirm(frage) -} - /** * Kreis-Häkchen (ok) / Kreis-Kreuz (nicht ok) / Kreis-Fragezeichen (unbekannt) * statt eines einfarbigen Punkts, für die Sicherheit-Seiten (Fahrzeugstatus, diff --git a/companion-app/src/screens/bestaetigung.test.tsx b/companion-app/src/screens/bestaetigung.test.tsx new file mode 100644 index 0000000..b28eace --- /dev/null +++ b/companion-app/src/screens/bestaetigung.test.tsx @@ -0,0 +1,51 @@ +/** + * Die Rückfrage ist ab jetzt das Tor vor JEDER löschenden Aktion der App - + * elf Aufrufstellen. Deshalb hier festgehalten, was sie können muss. + * + * Der Anlass: bis zum 04.09.2026 war es `window.confirm`, und das kehrt in + * eingebetteten Ansichten sofort mit `false` zurück - jede Löschaktion lief + * dort still ins Leere ("der Knopf tut nichts", gemeldet vom Eigentümer). + */ + +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" + +import { Bestaetigungsblatt, bestaetigen } from "./bestaetigung" + +describe("Rückfrage", () => { + it("antwortet mit true, wenn der bestätigende Knopf gedrückt wird", async () => { + render() + const antwort = bestaetigen("Fahrt löschen?", { text: "Das lässt sich nicht widerrufen." }) + await waitFor(() => expect(screen.getByText("Fahrt löschen?")).toBeTruthy()) + expect(screen.getByText("Das lässt sich nicht widerrufen.")).toBeTruthy() + fireEvent.click(screen.getByText("Löschen")) + expect(await antwort).toBe(true) + }) + + it("antwortet mit false bei Abbrechen und schliesst das Blatt", async () => { + render() + const antwort = bestaetigen("Tankvorgang löschen?") + await waitFor(() => expect(screen.getByText("Tankvorgang löschen?")).toBeTruthy()) + fireEvent.click(screen.getByText("Abbrechen")) + expect(await antwort).toBe(false) + await waitFor(() => expect(screen.queryByText("Tankvorgang löschen?")).toBeNull()) + }) + + it("nimmt die Knopfbeschriftung und laesst sich unauffaellig stellen", async () => { + render() + const antwort = bestaetigen("Neue Räder anlegen?", { knopf: "Anlegen", destruktiv: false }) + await waitFor(() => expect(screen.getByText("Anlegen")).toBeTruthy()) + expect(screen.getByText("Anlegen").className).not.toContain("loeschen") + fireEvent.click(screen.getByText("Anlegen")) + expect(await antwort).toBe(true) + }) + + it("faellt ohne gehaengtes Blatt auf den Systemdialog zurueck", async () => { + // Eine Rückfrage, die niemand beantworten kann, wäre schlechter als der + // Systemdialog - deshalb dieser Rückfall (Testlauf, Fremdeinbettung). + const stub = vi.spyOn(window, "confirm").mockReturnValue(true) + expect(await bestaetigen("Foto löschen?", { text: "Weg ist weg." })).toBe(true) + expect(stub).toHaveBeenCalledWith("Foto löschen?\n\nWeg ist weg.") + stub.mockRestore() + }) +}) diff --git a/companion-app/src/screens/bestaetigung.tsx b/companion-app/src/screens/bestaetigung.tsx new file mode 100644 index 0000000..248abc0 --- /dev/null +++ b/companion-app/src/screens/bestaetigung.tsx @@ -0,0 +1,158 @@ +/** + * Rückfrage vor einer löschenden Aktion — als eigenes Blatt, nicht als + * Browserdialog. + * + * WARUM NICHT window.confirm + * -------------------------- + * Bis zum 04.09.2026 war `bestaetigen()` genau das. Drei Gründe dagegen, + * einer davon gemessen: + * + * 1. Es funktioniert nicht überall. In eingebetteten Ansichten sind Dialoge + * abgeschaltet — `window.confirm` kehrt dort sofort mit `false` zurück + * (im Browser-Bereich dieser Sitzung nachgemessen: 1 ms). Jede löschende + * Aktion der App lief damit still ins Leere, sichtbar als „der Knopf tut + * nichts" (gemeldet vom Eigentümer für „Verbindung trennen"). + * 2. Es sieht nicht aus wie die App, sondern wie das Betriebssystem — und + * das Panel zeigt an derselben Stelle seit jeher sein eigenes Blatt + * (`bestaetigen()` -> `.sheet` in audi-dashboard-app.js). + * 3. Es kennt nur Titel und einen festen Knopf. Das Panel stellt Frage, + * erklärenden Satz und Knopfbeschriftung getrennt — `LOESCH_TEXT` und + * `LOESCH_HINWEIS` unten sind wortgleich von dort übernommen. + * + * WARUM EIN SPEICHER UND KEIN HOOK JE AUFRUFSTELLE + * ------------------------------------------------ + * Die Rückfrage steht an acht Stellen in sechs Komponenten. Ein Hook hätte + * jede davon zwingen, das Blatt auch selbst zu rendern — acht Stellen, an + * denen man es vergessen kann, ohne dass etwas auffällt (der Aufruf würde + * einfach nie antworten). Stattdessen hängt hier ein winziger Speicher, das + * Blatt wird EINMAL in App.tsx gehängt, und `bestaetigen()` bleibt für die + * Aufrufstellen fast unverändert — nur mit `await` davor. + * + * Dasselbe Muster wie `theme.ts`: Zustand außerhalb des Komponentenbaums, + * gelesen über `useSyncExternalStore`. + */ + +import { useEffect, useSyncExternalStore } from "react" + +import { ActionSheet } from "@audi-dash/ui" + +/** Wortgleich mit LOESCH_TEXT im Panel. */ +export const LOESCH_TEXT = { + trip: "Fahrt löschen?", + fill: "Tankvorgang löschen?", + sbuch: "Wartungsplan-Eintrag löschen?", + bild: "Foto löschen?", + batt: "Messwert löschen?", + rfarch: "Archivierten Radsatz löschen?", +} as const + +/** Wortgleich mit LOESCH_HINWEIS im Panel. */ +export const LOESCH_HINWEIS = { + trip: "Die Fahrt wird aus dem Fahrtenbuch entfernt. Das lässt sich nicht widerrufen.", + fill: "Der Tankvorgang und der zugehörige Beleg werden entfernt. Das lässt sich nicht widerrufen.", + sbuch: "Der Eintrag wird aus dem Wartungsplan entfernt. Das lässt sich nicht widerrufen.", + bild: "Das Foto wird vom Server gelöscht. Der Platz bleibt bestehen und kann neu belegt werden.", + batt: "Der Tageseintrag wird aus der Messwertliste entfernt. Das lässt sich nicht widerrufen.", + rfarch: + "Der archivierte Radsatz wird mit seiner Laufleistung entfernt. Das lässt sich nicht widerrufen.", +} as const + +export interface Rueckfrage { + /** Die Frage selbst, fett über den Knöpfen. */ + titel: string + /** Ein erklärender Satz darunter — was passiert, und ob es umkehrbar ist. */ + text?: string + /** Beschriftung des bestätigenden Knopfes. */ + knopf?: string + /** Rot, wenn etwas entfernt oder überschrieben wird. */ + destruktiv?: boolean +} + +/** Alles ausser der Frage selbst - die steht als erstes Argument. */ +export type Zusatz = Omit + +interface Offen extends Rueckfrage { + loesen: (antwort: boolean) => void +} + +let offen: Offen | null = null +let gehaengt = false +const hoerer = new Set<() => void>() + +function melden() { + for (const h of hoerer) h() +} + +function abonnieren(h: () => void) { + hoerer.add(h) + return () => { + hoerer.delete(h) + } +} + +/** + * Fragt nach und antwortet, sobald der Nutzer entschieden hat. + * + * Ist das Blatt nirgends gehängt (Testlauf, Fremdeinbettung), fällt es auf + * `window.confirm` zurück — eine Rückfrage, die niemand beantworten kann, + * wäre schlechter als der Systemdialog. + */ +export function bestaetigen(titel: string, angaben: Zusatz | string = {}): Promise { + const rest: Zusatz = typeof angaben === "string" ? { text: angaben } : angaben + if (!gehaengt) { + return Promise.resolve( + typeof window === "undefined" ? false : window.confirm(rest.text ? `${titel}\n\n${rest.text}` : titel), + ) + } + // Eine zweite Frage, während die erste noch steht, gibt es in der + // Oberfläche nicht - käme sie doch, gilt die ältere als abgelehnt, statt + // ihr Versprechen offen zu lassen. + offen?.loesen(false) + return new Promise((loesen) => { + offen = { ...rest, titel, loesen } + melden() + }) +} + +function lesen() { + return offen +} + +/** Einmal in der App gehängt (App.tsx). Rendert nichts, solange nichts gefragt ist. */ +export function Bestaetigungsblatt() { + const anfrage = useSyncExternalStore(abonnieren, lesen, () => null) + // Erst im Effekt, nicht waehrend des Renderns: bis dahin gilt der + // Rueckfall, und das ist genau richtig - vor dem Haengen kann das Blatt + // niemanden fragen. + useEffect(() => { + gehaengt = true + return () => { + gehaengt = false + } + }, []) + const antworten = (antwort: boolean) => { + const a = offen + offen = null + melden() + a?.loesen(antwort) + } + return ( + antworten(false)} + title={anfrage?.titel ?? ""} + {...(anfrage?.text ? { description: anfrage.text } : {})} + actions={ + anfrage + ? [ + { + label: anfrage.knopf ?? "Löschen", + destructive: anfrage.destruktiv ?? true, + onClick: () => antworten(true), + }, + ] + : [] + } + /> + ) +} diff --git a/custom_components/audi_dashboard/frontend/app/bundle.json b/custom_components/audi_dashboard/frontend/app/bundle.json index b87dcaa..7311a83 100644 --- a/custom_components/audi_dashboard/frontend/app/bundle.json +++ b/custom_components/audi_dashboard/frontend/app/bundle.json @@ -1 +1 @@ -{"version":"2026.9.4.16","sha256":"451fba321daec6b7281c7a8298670486c33cb2c09d2f2af5405d894792f4185f","bytes":321475,"gebaut":"2026-09-04T11:55:53Z"} \ No newline at end of file +{"version":"2026.9.4.17","sha256":"72442f3c75c5a9a467d552e55ce3892686d7bdecb7ff147920bf3bbcb78cb1e3","bytes":322203,"gebaut":"2026-09-04T12:06:58Z"} \ No newline at end of file diff --git a/custom_components/audi_dashboard/frontend/app/bundle.zip b/custom_components/audi_dashboard/frontend/app/bundle.zip index 8ec7fa7..6571b28 100644 Binary files a/custom_components/audi_dashboard/frontend/app/bundle.zip and b/custom_components/audi_dashboard/frontend/app/bundle.zip differ diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json index 06a65e7..b4b2926 100644 --- a/custom_components/audi_dashboard/manifest.json +++ b/custom_components/audi_dashboard/manifest.json @@ -1,7 +1,7 @@ { "domain": "audi_dashboard", "name": "Audi Dashboard", - "version": "2026.9.4.16", + "version": "2026.9.4.17", "documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md", "issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues", "codeowners": [