Kalender ueber EventKit, Orte nach dem Import, Fotos verkleinert, Signalsperre (2026.9.3.1-.3)

- Kalendertermine gehen nativ ueber EventKit statt ueber das Teilen-Blatt:
  Apples Kalender meldet sich beim System gar nicht als Teilen-Ziel an, ein
  anderes Dateiformat haette also nie geholfen (Abschnitt BS).
- Der Rueckblick stoesst jetzt selbst das Screening an, wenn er Fahrten
  angelegt hat - sonst blieb eine importierte Fahrt ohne Ort liegen, solange
  das Fahrzeug steht (Abschnitt BT).
- Fahrzeugfotos werden vor dem Upload im Browser auf 2000 px/WebP gebracht.
  Gemessen: 3.099.022 -> 325.994 Bytes. Damit ist die Nachrichtengrenze der
  WebSocket-Verbindung kein Thema mehr ("connection lost" auf der realen
  Instanz). Formate, die der Browser weder umwandeln noch anzeigen kann,
  werden mit klarer Meldung abgelehnt statt roh gespeichert.
- Zwei Wechsel des Fahrtsignals in derselben Sekunde kosteten eine ganze
  Fahrt: das "on" ueberholte den noch laufenden Beende-Vorgang und fiel durch
  beide Zweige. Neue asyncio-Sperre plus Regressionstest, der ohne sie
  nachweislich rot ist (Abschnitt BU).

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-03 01:59:32 +02:00
co-authored by Claude Opus 5
parent 8c95e50554
commit 6016053a92
18 changed files with 1072 additions and 78 deletions
+11 -6
View File
@@ -18,7 +18,7 @@ import { datum, de, deOderStrich } from "../format"
import { SymbolEdit, SymbolPaket, SymbolZahnradVoll } from "../symbole"
import { BildMitMenue, WischLoeschen, Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { Datumsfeld } from "./Datumsfeld"
import { kalenderDateiLaden } from "./kalender"
import { terminUebernehmen } from "./kalender"
type SatzSchluessel = "sommer" | "winter"
@@ -30,6 +30,7 @@ export function Reifen() {
const [kmOffen, setzeKmOffen] = useState<SatzSchluessel | null>(null)
const [kmEntwurf, setzeKmEntwurf] = useState("")
const [wechselDatum, setzeWechselDatum] = useState<string | null>(null)
const [kalenderFehler, setzeKalenderFehler] = useState<string | null>(null)
if (!fahrzeug) return null
@@ -51,6 +52,7 @@ export function Reifen() {
}
const kalenderUebernehmen = async () => {
setzeKalenderFehler(null)
await profilSpeichern({
fahrzeug: {
...fahrzeug,
@@ -58,11 +60,13 @@ export function Reifen() {
},
})
const ziel = aktiv === "Sommer" ? "Winter" : "Sommer"
await kalenderDateiLaden(
"Reifenwechsel",
wechselDatumAnzeige,
`Wechsel auf ${ziel}reifen`,
)
try {
await terminUebernehmen("Reifenwechsel", wechselDatumAnzeige, `Wechsel auf ${ziel}reifen`)
} catch (fehler) {
setzeKalenderFehler(
fehler instanceof Error ? fehler.message : "Der Termin ließ sich nicht übergeben.",
)
}
}
const kmSpeichern = async (satzSchluessel: SatzSchluessel) => {
@@ -309,6 +313,7 @@ export function Reifen() {
In den Kalender übernehmen
</ActionButton>
</div>
{kalenderFehler && <p className="dm-fehler">{kalenderFehler}</p>}
</Tile>
{satzTile("sommer")}
+11 -4
View File
@@ -32,7 +32,7 @@ import {
bestaetigen,
} from "./bausteine"
import { Datumsfeld } from "./Datumsfeld"
import { kalenderDateiLaden } from "./kalender"
import { terminUebernehmen } from "./kalender"
import { vcardParsen } from "./vcard"
export interface ServicebuchEintrag {
@@ -270,6 +270,7 @@ function TerminVereinbaren() {
Sobald jemand ein Datum wählt, bleibt es stehen, auch wenn die Art
wechselt - sonst würde die eigene Eingabe stillschweigend überschrieben. */
const [eigenesDatum, setzeEigenesDatum] = useState<string | null>(null)
const [kalenderFehler, setzeKalenderFehler] = useState<string | null>(null)
if (!fahrzeug) return null
@@ -319,17 +320,23 @@ function TerminVereinbaren() {
Panel (`ics()` dort). Fehlt das Autohaus, bleiben beide Felder leer
und der Termin trägt nur "was" und "wann". */}
<ActionButton
onClick={() =>
void kalenderDateiLaden(
onClick={() => {
setzeKalenderFehler(null)
void terminUebernehmen(
`${einstellungen?.fahrzeugtitel ?? "Fahrzeug"}: ${art}`,
datumWert,
autohaus.adresse ?? "",
autohaus.name ?? "",
).catch((fehler) =>
setzeKalenderFehler(
fehler instanceof Error ? fehler.message : "Der Termin ließ sich nicht übergeben.",
),
)
}
}}
>
In den Kalender übernehmen
</ActionButton>
{kalenderFehler && <p className="dm-fehler">{kalenderFehler}</p>}
</Tile>
)
}
+15 -8
View File
@@ -11,7 +11,7 @@ import type { SicherheitsPunkt } from "../api"
import { useDaten } from "../daten/DatenKontext"
import { SymbolZahnradVoll } from "../symbole"
import { Bild } from "./Bild"
import { bildVersionErneuern } from "./bilder"
import { bildAlsBase64, bildVerkleinern, bildVersionErneuern } from "./bilder"
/** Kraftstoffsorten zur Auswahl - dieselbe feste Liste wie `KRAFTSTOFFSORTEN`
im Panel, geteilt zwischen Tanken.tsx (Neuanlegen) und TankDetail.tsx
@@ -96,15 +96,16 @@ export function BildMitMenue({
}) {
const { api, jetztAktualisieren } = useDaten()
const [offen, setzeOffen] = useState(false)
/* Ein Fehlschlag beim Hochladen war bisher unsichtbar: der Aufruf lief als
`void hochladen(f)` ins Leere. Jetzt steht er unter dem Bild. */
const [fehler, setzeFehler] = useState<string | null>(null)
const datei_ = useRef<HTMLInputElement | null>(null)
const hochladen = async (gewaehlt: File) => {
const base64 = await new Promise<string>((fertig, fehler) => {
const leser = new FileReader()
leser.onload = () => fertig(String(leser.result).split(",")[1] ?? "")
leser.onerror = () => fehler(leser.error)
leser.readAsDataURL(gewaehlt)
})
setzeFehler(null)
/* Verkleinern vor dem Senden - Begründung in bilder.ts. Ein Foto direkt
aus der Kamera sprengt sonst die Nachrichtengrenze der Verbindung. */
const base64 = await bildAlsBase64(await bildVerkleinern(gewaehlt))
await api.bildHochladen(datei, base64)
/* Ohne diese beiden Zeilen bliebe das alte Foto stehen: die Adresse ist
unveraendert, und Home Assistant liefert /local/ mit 31 Tagen
@@ -133,10 +134,16 @@ export function BildMitMenue({
hidden
onChange={(e) => {
const f = e.target.files?.[0]
if (f) void hochladen(f)
if (f)
void hochladen(f).catch((problem) =>
setzeFehler(
problem instanceof Error ? problem.message : "Hochladen fehlgeschlagen.",
),
)
e.target.value = ""
}}
/>
{fehler && <p className="dm-fehler">{fehler}</p>}
{/* Auswahlblatt statt schwebendem Menü: zwei Wege, die der Nutzer
selbst angestoßen hat, plus ein abgesetztes Abbrechen - genau der
Zuschnitt, für den Apple das Aktionsblatt vorsieht. Das frühere
+100
View File
@@ -129,3 +129,103 @@ export function markenlogoUrl(): string | undefined {
if (!basis) return undefined
return `${basis}/audi_dashboard_static/bilder/shell-logo.svg`
}
/* Ein Fahrzeugfoto wird im Browser verkleinert, bevor es hochgeladen wird.
WARUM: der Upload läuft als Dienstaufruf über die WebSocket-Verbindung, und
die hat eine Obergrenze je Nachricht - aiohttps Vorgabe sind 4 MiB, und
Base64 bläht die Datei vorher um ein Drittel auf. Über die reale Instanz
kommt noch Cloudflare samt Vorschaltserver dazu, die enger sein können.
Genau das war der Befund vom 03.09.2026: dasselbe Cockpit-Foto ging in der
Testinstanz (localhost) durch und scheiterte über die reale Instanz mit
"connection lost" - eine abgerissene Verbindung, kein Fehler des Backends,
deshalb auch keine brauchbare Meldung.
Verkleinern löst das an der Wurzel statt die Grenze zu verschieben: die
größte Fläche, auf der ein Foto je erscheint, ist die Inhaltsspalte im
Breitbildlayout mit 772px. 2000px lange Kante lassen dafür reichlich Luft
(auch für Bildschirme mit doppelter Punktdichte), und WebP bei 0,85 bringt
ein solches Foto typischerweise auf einige hundert Kilobyte.
Fällt irgendein Schritt aus (Format nicht dekodierbar, kein WebP-Encoder,
Ergebnis größer als das Original), bleibt es beim Original - schlechter als
vorher wird es dadurch nie. Wortgleich im Panel. */
const MAX_KANTE = 2000
const QUALITAET = 0.85
export function bildAlsBase64(blob: Blob): Promise<string> {
return new Promise((fertig, fehler) => {
const leser = new FileReader()
leser.onload = () => fertig(String(leser.result).split(",")[1] ?? "")
leser.onerror = () => fehler(leser.error)
leser.readAsDataURL(blob)
})
}
/* Formate, die jeder Browser dieser App wirklich anzeigen kann.
Wichtig, weil das Backend nichts prüft: `bilder.py` schreibt die Bytes roh
unter einen der elf festen `.webp`-Namen. Ein Foto, das der Browser nicht
umwandeln konnte und das er auch nicht anzeigen kann, läge danach als
Bilddatei auf der Platte, die niemand darstellt - ohne Meldung.
Der praktische Fall ist HEIC: die iPhone-Fotoauswahl liefert in der Regel
JPEG, am Mac im Finder gewählt kommt aber eine `.heic` an, und die kann
ausser Safari/WKWebView niemand dekodieren. */
const SICHERE_TYPEN = /^image\/(jpeg|png|webp|gif|bmp|avif)$/i
const SICHERE_ENDUNGEN = /\.(jpe?g|png|webp|gif|bmp|avif)$/i
export function bildformatIstSicher(datei: File): boolean {
// Der MIME-Typ zählt, wenn er da ist. Aus dem Explorer gezogene Dateien
// kommen je nach Quelle mit leerem `type` an - dann die Endung, dieselbe
// Doppelprüfung wie bei `istPdf()` im Panel.
if (datei.type) return SICHERE_TYPEN.test(datei.type)
return SICHERE_ENDUNGEN.test(datei.name)
}
export class BildformatFehler extends Error {}
export async function bildVerkleinern(datei: File): Promise<Blob> {
let klein: Blob | null = null
const adresse = URL.createObjectURL(datei)
try {
const bild = await new Promise<HTMLImageElement>((fertig, fehler) => {
const i = new Image()
i.onload = () => fertig(i)
i.onerror = () => fehler(new Error("Bild nicht lesbar"))
i.src = adresse
})
const kante = Math.max(bild.naturalWidth, bild.naturalHeight)
if (kante) {
const faktor = Math.min(1, MAX_KANTE / kante)
const flaeche = document.createElement("canvas")
flaeche.width = Math.round(bild.naturalWidth * faktor)
flaeche.height = Math.round(bild.naturalHeight * faktor)
flaeche.getContext("2d")?.drawImage(bild, 0, 0, flaeche.width, flaeche.height)
klein = await new Promise<Blob | null>((fertig) =>
flaeche.toBlob(fertig, "image/webp", QUALITAET),
)
}
} catch (fehler) {
console.warn("Bild konnte nicht umgewandelt werden", fehler)
} finally {
URL.revokeObjectURL(adresse)
}
// Ein bereits sparsam gespeichertes Foto soll nicht ein zweites Mal durch
// eine verlustbehaftete Kodierung - das kostet Qualität ohne Gewinn.
if (klein && klein.size < datei.size) return klein
/* Umwandeln ging nicht (oder brachte nichts) - dann muss wenigstens das
Original anzeigbar sein. Sonst lieber jetzt eine klare Meldung als
hinterher ein leeres Bild, dessen Ursache niemand mehr sieht. */
if (!bildformatIstSicher(datei)) {
const art = datei.type || datei.name.split(".").pop() || "unbekannt"
throw new BildformatFehler(
`Dieses Bildformat kann der Browser nicht umwandeln (${art}). ` +
"Bitte das Foto als JPEG, PNG oder WebP sichern und noch einmal versuchen.",
)
}
return datei
}
+101
View File
@@ -0,0 +1,101 @@
/**
* Der Weg eines Termins auf dem Gerät.
*
* Geprüft wird die Entscheidung, nicht EventKit: dass ein natives Gerät das
* Plugin nimmt und NICHT das Teilen-Blatt (aus dem nie ein Kalender kam, siehe
* Kopf von kalender.ts), dass eine .ipa ohne das Plugin auf die Datei
* zurückfällt, und dass ein echter Fehler nicht hinter diesem Rückfall
* verschwindet.
*
* `@capacitor/*` wird ersetzt: sonst meldet `isNativePlatform()` "Browser" und
* die native Verzweigung wird nie betreten.
*/
import { beforeEach, describe, expect, it, vi } from "vitest"
const { nativ, dateien, teilen } = vi.hoisted(() => ({
nativ: { terminAnlegen: vi.fn() },
dateien: { writeFile: vi.fn() },
teilen: { share: vi.fn() },
}))
vi.mock("@capacitor/core", () => ({
Capacitor: { isNativePlatform: () => true },
registerPlugin: () => nativ,
}))
vi.mock("@capacitor/filesystem", () => ({
Filesystem: dateien,
Directory: { Cache: "CACHE" },
Encoding: { UTF8: "utf8" },
}))
vi.mock("@capacitor/share", () => ({ Share: teilen }))
/** Was Capacitor wirft, wenn die installierte App das Plugin nicht kennt. */
function pluginFehltFehler(): Error {
const fehler = new Error('KalenderTermin does not have an implementation of "terminAnlegen".')
;(fehler as Error & { code: string }).code = "UNIMPLEMENTED"
return fehler
}
beforeEach(() => {
vi.clearAllMocks()
dateien.writeFile.mockResolvedValue({ uri: "file:///cache/termin.ics" })
teilen.share.mockResolvedValue(undefined)
})
describe("Termin an den Kalender übergeben", () => {
it("nimmt auf dem Gerät das Plugin und nicht das Teilen-Blatt", async () => {
nativ.terminAnlegen.mockResolvedValue({ status: "gespeichert" })
const { terminUebernehmen } = await import("./kalender")
await terminUebernehmen("RS4: Ölwechsel", "2027-03-14", "Musterstr. 1", "Autohaus Muster")
expect(nativ.terminAnlegen).toHaveBeenCalledWith({
titel: "RS4: Ölwechsel",
isoTag: "2027-03-14",
beschreibung: "Musterstr. 1",
ort: "Autohaus Muster",
})
expect(teilen.share).not.toHaveBeenCalled()
expect(dateien.writeFile).not.toHaveBeenCalled()
})
/* Ein Abbruch im System-Editor ist kein Fehler - die Funktion kehrt still
zurück, damit die Oberfläche nichts Rotes zeigt. */
it("behandelt einen Abbruch wie einen Erfolg", async () => {
nativ.terminAnlegen.mockResolvedValue({ status: "abgebrochen" })
const { terminUebernehmen } = await import("./kalender")
await expect(terminUebernehmen("Reifenwechsel", "2026-10-15")).resolves.toBeUndefined()
expect(teilen.share).not.toHaveBeenCalled()
})
it("fällt auf die Datei zurück, wenn die App das Plugin nicht kennt", async () => {
nativ.terminAnlegen.mockRejectedValue(pluginFehltFehler())
const { terminUebernehmen } = await import("./kalender")
await terminUebernehmen("Audi RS4: Inspektion", "2027-01-01")
expect(dateien.writeFile).toHaveBeenCalledWith(
expect.objectContaining({ path: "audi-rs4-inspektion.ics" }),
)
expect(teilen.share).toHaveBeenCalledWith(
expect.objectContaining({ files: ["file:///cache/termin.ics"] }),
)
})
/* Der Rückfall gilt NUR für die fehlende Umsetzung. Sonst verschwände ein
echtes Problem hinter einem Teilen-Blatt, aus dem der Termin nie in den
Kalender fände. */
it("reicht einen echten Fehler durch, statt zu teilen", async () => {
nativ.terminAnlegen.mockRejectedValue(new Error("Kein Fenster für den Kalender-Editor."))
const { terminUebernehmen } = await import("./kalender")
await expect(terminUebernehmen("Reifenwechsel", "2026-10-15")).rejects.toThrow(
"Kein Fenster für den Kalender-Editor.",
)
expect(teilen.share).not.toHaveBeenCalled()
})
})
+81 -33
View File
@@ -1,26 +1,59 @@
/**
* Termine als Kalenderdatei. Portiert aus `ics()` im alten Panel.
* Termine an den Kalender übergeben.
*
* Erzeugt eine .ics-Datei ohne Server, mit Erinnerung zwei Tage vorher.
* DREI WEGE, WEIL DREI UMGEBUNGEN
* -------------------------------
* **Auf dem Gerät** geht der Termin über EventKit direkt in den Kalender
* (native/KalenderTermin.swift): der System-Editor erscheint fertig ausgefüllt,
* ein Tipp auf „Hinzufügen" genügt.
*
* ZWEI WEGE, WEIL EIN DOWNLOAD IN DER APP NICHT ANKOMMT
* -----------------------------------------------------
* Im Browser und im HA-Panel genügt ein `<a download>` mit einer
* Blob-Adresse. In der nativen Hülle nicht: **WKWebView ignoriert das
* `download`-Attribut**, der Klick läuft ins Leere und es passiert sichtbar
* nichts — genau so gemeldet („Wechseltermin in Kalender übernehmen klappt
* nicht", 2026-08-31).
* Vorher lief auch dort der Datei-Weg, und der konnte nie ankommen. Nicht wegen
* der Datei: **Apples Kalender-App meldet sich beim System gar nicht als
* Teilen-Ziel an**, für kein Format — das Teilen-Blatt kann sie deshalb
* überhaupt nicht anbieten. Dass ein per Mail geschickter Anhang funktioniert,
* führt in die Irre; dort öffnet die Dokumentvorschau des Systems (Quick Look)
* mit ihrem eigenen „Alle hinzufügen", nicht das Teilen-Blatt. Gemeldet vom
* Eigentümer am 02.09.2026, nachdem der Dateiname am selben Tag repariert war
* und es trotzdem nicht half.
*
* Auf dem Gerät geht die Datei deshalb ins temporäre Verzeichnis und von
* dort ins iOS-Teilen-Blatt, aus dem „Zu Kalender hinzufügen" kommt. Das ist
* auch der ehrlichere Ablauf: man sieht, wohin der Termin geht, statt dass
* eine Datei stillschweigend irgendwo landet.
* **Der Datei-Weg bleibt als Rückfall** für eine .ipa von vor dem 03.09.2026,
* die das Plugin noch nicht kennt. Ein Kalender steht darin weiterhin nicht zur
* Auswahl, aber die Datei lässt sich in „Dateien" sichern und von dort öffnen —
* besser als ein Knopf, der gar nichts tut.
*
* **Im Browser und im HA-Panel** bleibt es beim gewöhnlichen Download; dort
* übernimmt das Betriebssystem die .ics wie jede andere Datei. (In der nativen
* Hülle ginge das nicht: **WKWebView ignoriert das `download`-Attribut**, der
* Klick lief sichtbar ins Leere — so gemeldet am 2026-08-31.)
*/
import { Capacitor } from "@capacitor/core"
import { Capacitor, registerPlugin } from "@capacitor/core"
import { Directory, Encoding, Filesystem } from "@capacitor/filesystem"
import { Share } from "@capacitor/share"
interface KalenderTermin {
/** `status`: "gespeichert" oder "abgebrochen" - ein Abbruch ist kein Fehler. */
terminAnlegen(angaben: {
titel: string
isoTag: string
beschreibung: string
ort: string
}): Promise<{ status: string }>
}
/** Muss zu `jsName` in KalenderTermin.swift passen. */
const Nativ = registerPlugin<KalenderTermin>("KalenderTermin")
/**
* Unterscheidet „dieses Gerät kennt das Plugin nicht" von einem echten Fehler.
* Nur im ersten Fall ist der Rückfall auf die Datei richtig - sonst verschwände
* ein echtes Problem hinter einem Teilen-Blatt, aus dem der Termin nie in den
* Kalender fände.
*/
function pluginFehlt(fehler: unknown): boolean {
return (fehler as { code?: string } | null)?.code === "UNIMPLEMENTED"
}
function zeilenUmbruch(text: string): string {
return text.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n")
}
@@ -104,37 +137,52 @@ function dateiname(titel: string): string {
return `${stamm || "termin"}.ics`
}
export async function kalenderDateiLaden(
/** Der Rückfall für Geräte ohne das Plugin - siehe Kopf dieser Datei. */
async function ueberTeilenBlatt(
titel: string,
isoTag: string,
beschreibung: string,
ort: string,
): Promise<void> {
/* Cache statt Documents: der Termin ist im Kalender aufgehoben, sobald er
dort ist - die Zwischendatei muss nicht überleben, und iOS darf den
Cache jederzeit räumen. */
const geschrieben = await Filesystem.writeFile({
path: dateiname(titel),
data: kalenderDatei(titel, isoTag, beschreibung, ort),
directory: Directory.Cache,
encoding: Encoding.UTF8,
})
/* files statt url: beides landet im Plugin als URL in den activityItems,
aber `files` ist der dokumentierte Weg für lokale Dateien - `url` ist
für Links gedacht. dialogTitle ist Android-only und schadet hier nicht. */
await Share.share({ title: titel, files: [geschrieben.uri], dialogTitle: titel })
}
export async function terminUebernehmen(
titel: string,
isoTag: string,
beschreibung = "",
ort = "",
): Promise<void> {
const inhalt = kalenderDatei(titel, isoTag, beschreibung, ort)
const name = dateiname(titel)
if (Capacitor.isNativePlatform()) {
/* Cache statt Documents: der Termin ist im Kalender aufgehoben, sobald er
dort ist - die Zwischendatei muss nicht überleben, und iOS darf den
Cache jederzeit räumen. */
const geschrieben = await Filesystem.writeFile({
path: name,
data: inhalt,
directory: Directory.Cache,
encoding: Encoding.UTF8,
})
/* files statt url: beides landet im Plugin als URL in den activityItems,
aber `files` ist der dokumentierte Weg für lokale Dateien - `url` ist
für Links gedacht. dialogTitle ist Android-only und schadet hier nicht. */
await Share.share({ title: titel, files: [geschrieben.uri], dialogTitle: titel })
try {
await Nativ.terminAnlegen({ titel, isoTag, beschreibung, ort })
return
} catch (fehler) {
if (!pluginFehlt(fehler)) throw fehler
}
await ueberTeilenBlatt(titel, isoTag, beschreibung, ort)
return
}
const blob = new Blob([inhalt], { type: "text/calendar;charset=utf-8" })
const blob = new Blob([kalenderDatei(titel, isoTag, beschreibung, ort)], {
type: "text/calendar;charset=utf-8",
})
const url = URL.createObjectURL(blob)
const verweis = document.createElement("a")
verweis.href = url
verweis.download = name
verweis.download = dateiname(titel)
document.body.appendChild(verweis)
verweis.click()
verweis.remove()