Hinweis beim Oeffnen, wenn eine neuere Fassung bereitliegt

Bisher gab es dafuer nur den schmalen Streifen der Hinweisleiste, der
dauerhaft mitlaeuft und leicht uebersehen wird. Ein neuer Stand ist aber ein
Ereignis und gehoert einmal nach vorn.

Zwei Faelle, zwei Texte: liegt ein passendes OTA-Buendel bereit, traegt das
Blatt den Knopf, der die App direkt erneuert (derselbe Weg wie in den
Einstellungen). Fehlt eines, hat sich Natives geaendert - dann sagt es das
und bietet keinen Knopf an, der nichts bewirken koennte.

Nur nativ: im Browser und im Panel laedt jeder Aufruf den aktuellen Stand.
Einmal je Fassung: die weggetippte Serverfassung wird gemerkt, erst eine
andere bringt das Blatt zurueck.

Die Entscheidung steckt in der reinen Funktion hinweisFaellig(), sechs Faelle
getestet. 179/179 Tests gruen, tsc sauber.
This commit is contained in:
Paul Nothaft
2026-09-03 09:50:44 +02:00
parent 2c466bf5a2
commit c254cf5e2d
4 changed files with 219 additions and 0 deletions
+37
View File
@@ -8690,3 +8690,40 @@ WebP sichern und noch einmal versuchen." — und auf der Platte entsteht **nicht
Verifiziert: 5/5 Backend-Tests (und ohne die Sperre nachweislich rot), `tsc --noEmit` sauber,
173/173 App-Tests, Panel als Modul geparst, `audi_ha_test` auf `2026.9.3.3` sauber gestartet.
---
## AL. Popup beim Öffnen, wenn eine neuere Fassung bereitliegt (2026-09-03)
Wunsch des Besitzers: die App soll beim Start sagen, dass es eine neue Fassung gibt — so wie andere
Apps es tun, nur ohne App Store. Es gab bisher nur den schmalen Streifen der `Hinweisleiste`
("Diese App ist älter als der Server"), der dauerhaft mitläuft und deshalb leicht übersehen wird.
Neu: `companion-app/src/screens/Versionshinweis.tsx`, ein `ActionSheet`, eingehängt in `App.tsx`
neben der bestehenden Leiste. Die beiden ersetzen einander **nicht** — ein neuer Stand ist ein
Ereignis und gehört einmal nach vorn, danach übernimmt wieder der Streifen für den fortbestehenden
Zustand.
**Zwei Fälle, zwei Texte, weil zwei verschiedene Dinge zu tun sind.** Liegt ein passendes OTA-Bündel
bereit (`buendelPasst()`), kann die App sich selbst erneuern — dann trägt das Blatt den Knopf, der
genau das auslöst, über denselben `buendelAnwenden()`-Weg wie der Knopf in den Einstellungen. Fehlt
eines, hat sich Natives geändert und es hilft nur neu aufspielen; dann sagt das Blatt genau das und
bietet keinen Knopf an, der nichts bewirken könnte.
**Nur nativ.** Im Browser und im HA-Panel lädt jeder Aufruf den aktuellen Stand — dort kann die App
gar nicht veralten, dieselbe Begründung wie in `appVersion.ts`.
**Einmal je Fassung, nicht bei jedem Start.** Weggetippt wird die gesehene Serverfassung in
`localStorage` gemerkt; erst eine *andere* bringt das Blatt zurück. Ohne das wäre es binnen einer
Woche unsichtbar geworden. Die Entscheidung steckt in der reinen Funktion `hinweisFaellig()` und ist
mit sechs Fällen getestet (`Versionshinweis.test.ts`) — ein Fehler dort hat genau zwei Ausprägungen,
beide schlecht: eine Meldung bei jedem Start, die niemand mehr liest, oder ein Update, von dem nie
jemand erfährt.
**Geerbte Ungenauigkeit, bewusst nicht angefasst:** der Vergleich ist reine Gleichheit (siehe
`VERSIONIERUNG.md`), also meldet das Blatt auch dann "neue Version", wenn die App *neuer* ist als das
Backend — etwa direkt nach einem Xcode-Bau, bevor die Integration nachgezogen wurde. Der
Hinweisstreifen hat dieselbe Eigenschaft seit jeher. Sortierbar zu vergleichen wäre scheingenau; die
Alternative wäre ein zweites Signal vom Backend, und das lohnt für diesen Randfall nicht.
Verifiziert: `tsc --noEmit` sauber, 179/179 Tests (6 neue).
+8
View File
@@ -11,6 +11,7 @@ import type { Route, SeitenName } from "./navigation"
import { useTabBeschriftung, useTheme } from "./theme"
import { Einrichtung } from "./screens/Einrichtung"
import { Hinweisleiste } from "./screens/Hinweisleiste"
import { Versionshinweis } from "./screens/Versionshinweis"
import { SeiteFuer } from "./screens/register"
import { zugangMerken } from "./screens/zugang"
@@ -141,6 +142,13 @@ function AngemeldeteApp({ beiAbmeldung }: { beiAbmeldung: () => void }) {
setzeTabBeschriftung={setzeTabBeschriftung}
beiAbmeldung={beiAbmeldung}
/>
{/* Erscheint von selbst, sobald der erste Datenabruf eine neuere
Fassung meldet - siehe screens/Versionshinweis.tsx. */}
<Versionshinweis
versionsstand={daten.versionsstand}
serverVersion={daten.serverVersion}
otaBuendel={daten.otaBuendel}
/>
</Shell>
)
}
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest"
import { hinweisFaellig } from "./Versionshinweis"
/* hinweisFaellig() entscheidet, ob der Nutzer von einer neuen Fassung
erfährt. Ein Fehler hier hat genau zwei Ausprägungen, beide schlecht: eine
Meldung bei jedem Start, die niemand mehr liest, oder ein Update, von dem
nie jemand erfährt. */
describe("hinweisFaellig", () => {
it("meldet, wenn App und Server auseinanderlaufen", () => {
expect(hinweisFaellig("abweichend", "2026.9.3.3", null)).toBe(true)
})
it("schweigt bei gleichem Stand", () => {
expect(hinweisFaellig("gleich", "2026.9.3.3", null)).toBe(false)
})
it("schweigt, solange kein Vergleich möglich ist", () => {
// "unbekannt" heißt offline oder altes Backend - kein Grund für eine
// Meldung, sonst erschiene sie bei jedem Start ohne Verbindung.
expect(hinweisFaellig("unbekannt", null, null)).toBe(false)
expect(hinweisFaellig("unbekannt", "2026.9.3.3", null)).toBe(false)
})
it("schweigt ohne Serverfassung, auch wenn abweichend gemeldet wird", () => {
expect(hinweisFaellig("abweichend", null, null)).toBe(false)
})
it("schweigt zur bereits weggetippten Fassung", () => {
expect(hinweisFaellig("abweichend", "2026.9.3.3", "2026.9.3.3")).toBe(false)
})
it("meldet erneut, sobald eine andere Fassung bereitliegt", () => {
// Der Kern der Merkfunktion: weggetippt gilt für genau eine Fassung,
// nicht für "Updates" im Allgemeinen.
expect(hinweisFaellig("abweichend", "2026.9.4.1", "2026.9.3.3")).toBe(true)
})
})
@@ -0,0 +1,136 @@
/**
* Meldung beim Öffnen, wenn eine neuere Fassung bereitliegt.
*
* Warum zusätzlich zur Hinweisleiste: die Leiste ist ein schmaler Streifen,
* der dauerhaft mitläuft — richtig für einen Zustand, der bestehen bleibt,
* aber leicht zu übersehen. Dass eine neue Fassung *neu* da ist, ist dagegen
* ein Ereignis und gehört einmal nach vorn. Danach übernimmt wieder die
* Leiste; die beiden ersetzen einander nicht.
*
* Nur in der nativen Hülle. Im Browser und im HA-Panel lädt jeder Aufruf den
* aktuellen Stand, dort kann die App gar nicht veralten (dieselbe Begründung
* wie in daten/appVersion.ts).
*
* ZWEI FÄLLE, ZWEI TEXTE
* ----------------------
* Liegt ein passendes OTA-Bündel bereit, kann die App sich selbst erneuern —
* dann steht hier der Knopf, der genau das tut. Fehlt eines, hat sich Natives
* geändert (Plugins, Berechtigungen, die Hülle selbst), und dagegen hilft nur
* neu aufspielen. Beides zu vermengen wäre die schlechtere Auskunft: ein
* "Jetzt aktualisieren", das nichts ändern kann, oder ein "bitte manuell",
* obwohl ein Knopf genügt hätte.
*
* EINMAL, NICHT BEI JEDEM START
* -----------------------------
* Weggetippt wird die gesehene Serverfassung gemerkt. Erst eine *andere*
* bringt die Meldung zurück — sonst wäre sie bei jedem Start dieselbe und
* damit binnen einer Woche unsichtbar.
*/
import { useState } from "react"
import { ActionSheet } from "@audi-dash/ui"
import { zugangLesen, type Buendelangabe } from "../api"
import { eigeneVersion, type Versionsstand } from "../daten/appVersion"
import { OtaFehler, buendelAnwenden, buendelPasst, otaMoeglich } from "../daten/ota"
const SCHLUESSEL = "dm360.version.gesehen"
/**
* Soll die Meldung erscheinen?
*
* Bewusst als reine Funktion: sie entscheidet, ob der Nutzer von einer neuen
* Fassung erfährt oder nicht, und das soll prüfbar sein, ohne die Oberfläche
* zu bauen.
*/
export function hinweisFaellig(
versionsstand: Versionsstand,
serverVersion: string | null,
gesehen: string | null,
): boolean {
// "unbekannt" heißt: kein Vergleich möglich (offline, altes Backend). Das
// ist kein Grund für eine Meldung — genauso wenig wie "gleich".
if (versionsstand !== "abweichend") return false
if (!serverVersion) return false
return serverVersion !== gesehen
}
function gesehenLesen(): string | null {
try {
return localStorage.getItem(SCHLUESSEL)
} catch {
// Privater Modus oder gesperrter Speicher: dann eben jedes Mal fragen.
return null
}
}
function gesehenSchreiben(version: string): void {
try {
localStorage.setItem(SCHLUESSEL, version)
} catch {
/* siehe oben */
}
}
export function Versionshinweis({
versionsstand,
serverVersion,
otaBuendel,
}: {
versionsstand: Versionsstand
serverVersion: string | null
otaBuendel: Buendelangabe | null
}) {
const [weggetippt, setzeWeggetippt] = useState(false)
const [laeuft, setzeLaeuft] = useState(false)
const [fehler, setzeFehler] = useState<string | null>(null)
const nativ = otaMoeglich()
const offen =
nativ && !weggetippt && hinweisFaellig(versionsstand, serverVersion, gesehenLesen())
const perOta = buendelPasst(otaBuendel, eigeneVersion())
const schliessen = () => {
if (laeuft) return
if (serverVersion) gesehenSchreiben(serverVersion)
setzeWeggetippt(true)
}
const jetztLaden = async () => {
if (!otaBuendel) return
setzeLaeuft(true)
setzeFehler(null)
try {
const zugang = await zugangLesen()
if (!zugang) throw new OtaFehler("Kein Zugang eingerichtet.")
// Kehrt im Erfolgsfall nicht zurück — set() lädt die App neu.
await buendelAnwenden(otaBuendel, zugang.basisUrl)
} catch (f) {
setzeFehler(f instanceof Error ? f.message : "Das Update konnte nicht installiert werden.")
setzeLaeuft(false)
}
}
return (
<ActionSheet
open={offen}
onClose={schliessen}
title="Neue Version verfügbar"
description={
perOta
? `Diese App läuft auf ${eigeneVersion() ?? "einer unbekannten Fassung"}, bereit liegt ${serverVersion}. Sie kann sich direkt hier erneuern.`
: `Diese App läuft auf ${eigeneVersion() ?? "einer unbekannten Fassung"}, der Server liefert ${serverVersion}. Diese Fassung ändert auch Natives — dafür muss die App neu aufgespielt werden, ein Update in der App genügt nicht.`
}
actions={
perOta
? [{ label: laeuft ? "Wird geladen …" : "Jetzt aktualisieren", onClick: () => void jetztLaden(), disabled: laeuft }]
: []
}
cancelLabel={perOta ? "Später" : "Verstanden"}
>
{fehler ? <p className="dm-einrichtung__fehler">{fehler}</p> : null}
</ActionSheet>
)
}