strom: Live-Strom baut sich selbst neu auf, Verbindungsanzeige auf jeder Breite

- Gibt EventSource endgueltig auf (502/503 vom Proxy, waehrend MC2 neu startet), baut
  die App den Strom selbst neu auf: Pause 2 s, 4 s, 8 s ... bis 30 s, sofort wenn Netz
  oder Tab zurueck sind.
- Stille-Waechter: Der Strom schickt jede Sekunde einen Messpunkt. Kommt 20 s nichts,
  gilt die Leitung als tot (Handy im Standby, WLAN-Wechsel, Proxy haelt offen) und wird
  neu aufgebaut. Im Test mit einer Attrappe: vorher blieb "Box online" stehen.
- "Box online / Getrennt" steht jetzt auf jeder Breite in der Kopfzeile (am Handy
  kleiner, getrennt in Bernstein), nicht mehr nur im Mehr-Menue.
- Obere Navigation erst ab 1024 px, darunter die Leiste unten wie am Handy: Zwischen
  768 und ~880 px lief die Kopfzeile schon vorher seitlich ueber, mit der Anzeige bis 1023.
- metrikEinspeisen (nur fuer Tests gedacht, nie benutzt) entfaellt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 15:35:52 +02:00
co-authored by Claude Opus 5.5
parent 739f27a7e5
commit 634ad27302
5 changed files with 193 additions and 47 deletions
+5 -5
View File
@@ -1,10 +1,11 @@
import type { ReactNode } from "react"
import { ExternalLink, ScrollText, Settings } from "lucide-react" import { ExternalLink, ScrollText, Settings } from "lucide-react"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet" import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
/** Handy-Menü „Mehr“: Hermes, Dienste, Einstellungen. Eigene Datei, damit es erst beim Öffnen lädt. */ /**
export function MehrMenue({ verbindung, onDienste, onEinstellungen, onSchliessen }: { * Handy-Menü „Mehr“: Hermes, Dienste, Einstellungen. Eigene Datei, damit es erst beim Öffnen lädt.
verbindung: ReactNode * Die Verbindungsanzeige steht seit 09/2026 auf jeder Breite in der Kopfzeile, nicht mehr hier.
*/
export function MehrMenue({ onDienste, onEinstellungen, onSchliessen }: {
onDienste: () => void onDienste: () => void
onEinstellungen: () => void onEinstellungen: () => void
onSchliessen: () => void onSchliessen: () => void
@@ -17,7 +18,6 @@ export function MehrMenue({ verbindung, onDienste, onEinstellungen, onSchliessen
<SheetDescription className="sr-only">Weitere Bereiche</SheetDescription> <SheetDescription className="sr-only">Weitere Bereiche</SheetDescription>
</SheetHeader> </SheetHeader>
<div className="flex flex-col gap-2 px-4"> <div className="flex flex-col gap-2 px-4">
{verbindung}
<a href="/hermes-ui/" target="_blank" rel="noopener" className="flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-base"> <a href="/hermes-ui/" target="_blank" rel="noopener" className="flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-base">
<ExternalLink className="size-5" aria-hidden /> Hermes-Dashboard öffnen <ExternalLink className="size-5" aria-hidden /> Hermes-Dashboard öffnen
</a> </a>
+2 -2
View File
@@ -8,13 +8,13 @@ const FARBE = {
info: "border-cyan-rand bg-cyan-grund text-cyan-text", info: "border-cyan-rand bg-cyan-grund text-cyan-text",
} }
/** Rückmeldungen unten rechts (am Handy über der Tab-Leiste), für Screenreader angesagt. */ /** Rückmeldungen unten rechts (unter 1024 px über der Tab-Leiste), für Screenreader angesagt. */
export function Meldungen() { export function Meldungen() {
const liste = useMeldungen() const liste = useMeldungen()
return ( return (
<div <div
aria-live="polite" aria-live="polite"
className="pointer-events-none fixed right-4 bottom-24 left-4 z-50 flex flex-col items-end gap-2 md:bottom-6 md:left-auto md:w-[420px]" className="pointer-events-none fixed right-4 bottom-24 left-4 z-50 flex flex-col items-end gap-2 md:left-auto md:w-[420px] lg:bottom-6"
> >
{liste.map((m) => ( {liste.map((m) => (
<div <div
+21 -14
View File
@@ -34,15 +34,23 @@ function Uhr() {
) )
} }
/** „Box online“ / „Getrennt“ — auf jeder Breite sichtbar, am Handy etwas kleiner. */
function Verbindung() { function Verbindung() {
const lage = useStromLage() const lage = useStromLage()
const live = lage === "live" const live = lage === "live"
return ( return (
<div className="schild flex items-center gap-2 text-[15px] text-text-3" aria-live="polite"> <div
role="status"
className={cn(
"schild flex shrink-0 items-center gap-1.5 text-[13px] tracking-[0.1em] sm:gap-2 sm:text-[15px] sm:tracking-[0.16em]",
live ? "text-text-3" : "text-bernstein",
)}
>
<span <span
className={cn("size-2 rounded-full", live ? "bg-gruen shadow-[0_0_8px_rgba(61,220,138,0.7)]" : "bg-bernstein")} aria-hidden
className={cn("size-2 shrink-0 rounded-full", live ? "bg-gruen shadow-[0_0_8px_rgba(61,220,138,0.7)]" : "bg-bernstein")}
/> />
<span>{live ? "Box online" : lage === "verbinde" ? "Verbinde" : "Getrennt"}</span> <span className="whitespace-nowrap">{live ? "Box online" : lage === "verbinde" ? "Verbinde" : "Getrennt"}</span>
</div> </div>
) )
} }
@@ -69,10 +77,12 @@ export function Shell() {
const zu = () => setSchublade(null) const zu = () => setSchublade(null)
return ( return (
<div className="mx-auto flex min-h-dvh w-full max-w-[1440px] flex-col gap-5 px-4 pt-4 pb-28 sm:px-6 md:gap-6 md:px-10 md:pt-8 md:pb-10"> <div className="mx-auto flex min-h-dvh w-full max-w-[1440px] flex-col gap-5 px-4 pt-4 pb-28 sm:px-6 md:gap-6 md:px-10 md:pt-8 lg:pb-10">
<header className="flex items-center justify-between gap-4 md:gap-6"> {/* Obere Navigation erst ab 1024 px: darunter passen Logo, Navigation, drei Knöpfe, Verbindung
und Uhr nicht nebeneinander — Tablets bekommen die Leiste unten wie das Handy. */}
<header className="flex items-center justify-between gap-4 lg:gap-6">
<Marke /> <Marke />
<nav aria-label="Hauptnavigation" className="hidden gap-1 rounded-xl border border-linie bg-[#121518] p-1 md:flex"> <nav aria-label="Hauptnavigation" className="hidden gap-1 rounded-xl border border-linie bg-[#121518] p-1 lg:flex">
{NAV.map(({ to, label }) => ( {NAV.map(({ to, label }) => (
<Link <Link
key={to} key={to}
@@ -85,11 +95,9 @@ export function Shell() {
</Link> </Link>
))} ))}
</nav> </nav>
<div className="flex items-center gap-4 md:gap-5"> <div className="flex items-center gap-3 lg:gap-5">
<div className="hidden lg:block"> <Verbindung />
<Verbindung /> <div className="hidden items-center gap-1 lg:flex">
</div>
<div className="hidden items-center gap-1 md:flex">
<a <a
href="/hermes-ui/" href="/hermes-ui/"
target="_blank" target="_blank"
@@ -127,10 +135,10 @@ export function Shell() {
<Outlet /> <Outlet />
</main> </main>
{/* Handy: Leiste unten, mit Abstand zur Wisch-Leiste des Telefons. */} {/* Handy und Tablet: Leiste unten, mit Abstand zur Wisch-Leiste des Telefons. */}
<nav <nav
aria-label="Hauptnavigation" aria-label="Hauptnavigation"
className="fixed inset-x-0 bottom-0 z-40 grid grid-cols-4 gap-1 border-t border-linie bg-[#0e1119]/95 px-2 pt-2 pb-[calc(0.75rem+env(safe-area-inset-bottom))] backdrop-blur md:hidden" className="fixed inset-x-0 bottom-0 z-40 grid grid-cols-4 gap-1 border-t border-linie bg-[#0e1119]/95 px-2 pt-2 pb-[calc(0.75rem+env(safe-area-inset-bottom))] backdrop-blur lg:hidden"
> >
{NAV.map(({ to, label, Icon }) => ( {NAV.map(({ to, label, Icon }) => (
<Link <Link
@@ -157,7 +165,6 @@ export function Shell() {
<Suspense fallback={null}> <Suspense fallback={null}>
{schublade === "mehr" && ( {schublade === "mehr" && (
<MehrMenue <MehrMenue
verbindung={<Verbindung />}
onDienste={() => setSchublade("dienste")} onDienste={() => setSchublade("dienste")}
onEinstellungen={() => setSchublade("einstellungen")} onEinstellungen={() => setSchublade("einstellungen")}
onSchliessen={zu} onSchliessen={zu}
+72
View File
@@ -0,0 +1,72 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { act, render, screen } from "@testing-library/react"
import { STILLE_MS, useStrom, useStromLage } from "./strom"
/** EventSource-Attrappe: Der Test entscheidet, wann geöffnet wird und wann sie aufgibt. */
class FalscheQuelle {
static CONNECTING = 0
static OPEN = 1
static CLOSED = 2
static alle: FalscheQuelle[] = []
readyState = FalscheQuelle.CONNECTING
onopen: (() => void) | null = null
onerror: (() => void) | null = null
constructor(public url: string) {
FalscheQuelle.alle.push(this)
}
addEventListener() {}
close() {
this.readyState = FalscheQuelle.CLOSED
}
oeffnen() {
this.readyState = FalscheQuelle.OPEN
this.onopen?.()
}
aufgeben() {
this.readyState = FalscheQuelle.CLOSED
this.onerror?.()
}
}
function Anzeige() {
useStrom()
return <span>{useStromLage()}</span>
}
describe("useStrom", () => {
beforeEach(() => {
vi.useFakeTimers()
FalscheQuelle.alle = []
vi.stubGlobal("EventSource", FalscheQuelle)
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
it("baut den Strom selbst neu auf, wenn EventSource aufgibt oder die Leitung verstummt", () => {
render(
<QueryClientProvider client={new QueryClient()}>
<Anzeige />
</QueryClientProvider>,
)
const erste = FalscheQuelle.alle[0]
act(() => erste.oeffnen())
expect(screen.getByText("live")).toBeInTheDocument()
// 503 vom Proxy, während MC2 neu startet: EventSource gibt für immer auf.
act(() => erste.aufgeben())
expect(screen.getByText("getrennt")).toBeInTheDocument()
act(() => vi.advanceTimersByTime(2_000))
expect(FalscheQuelle.alle).toHaveLength(2)
// Leitung offen, aber es kommt nichts mehr (Handy im Standby): nach STILLE_MS neu.
const zweite = FalscheQuelle.alle[1]
act(() => zweite.oeffnen())
act(() => vi.advanceTimersByTime(STILLE_MS))
expect(screen.getByText("getrennt")).toBeInTheDocument()
expect(zweite.readyState).toBe(FalscheQuelle.CLOSED)
act(() => vi.advanceTimersByTime(2_000))
expect(FalscheQuelle.alle).toHaveLength(3)
})
})
+93 -26
View File
@@ -7,6 +7,13 @@
// //
// Der Messpunkt liegt bewusst AUSSERHALB von React (useSyncExternalStore): So zeichnen // Der Messpunkt liegt bewusst AUSSERHALB von React (useSyncExternalStore): So zeichnen
// nur die Instrumente jede Sekunde neu, nicht die ganze Seite. // nur die Instrumente jede Sekunde neu, nicht die ganze Seite.
//
// Wiederaufbau: Bei einem Netzfehler versucht der Browser es von selbst erneut. Antwortet der
// Server aber mit einem Fehler (502/503 vom Proxy, während MC2 neu startet), gibt EventSource
// für immer auf — dann bauen wir ihn selbst neu auf, mit wachsender Pause (2 s bis 30 s) und
// sofort, wenn das Netz oder der Tab zurück ist. Dazu ein Stille-Wächter: Der Strom schickt jede
// Sekunde einen Messpunkt. Kommt 20 s lang nichts, ist die Leitung tot, ohne dass der Browser es
// merkt (Handy im Standby, WLAN gewechselt, Proxy hält die Verbindung offen) — auch dann neu.
import { useEffect, useSyncExternalStore } from "react" import { useEffect, useSyncExternalStore } from "react"
import { useQueryClient } from "@tanstack/react-query" import { useQueryClient } from "@tanstack/react-query"
@@ -27,6 +34,11 @@ export interface MetrikPunkt {
export type StromLage = "verbinde" | "live" | "getrennt" export type StromLage = "verbinde" | "live" | "getrennt"
const PAUSE_ERST_MS = 2_000
const PAUSE_HOECHST_MS = 30_000
/** So lange darf der Strom schweigen, bevor er als tot gilt (er schickt jede Sekunde einen Messpunkt). */
export const STILLE_MS = 20_000
let lage: StromLage = "verbinde" let lage: StromLage = "verbinde"
let letzte: MetrikPunkt | null = null let letzte: MetrikPunkt | null = null
const hoerer = new Set<() => void>() const hoerer = new Set<() => void>()
@@ -43,12 +55,6 @@ export const stromVerbunden = () => lage === "live"
export const useStromLage = () => useSyncExternalStore(abonniere, () => lage) export const useStromLage = () => useSyncExternalStore(abonniere, () => lage)
export const useLetzteMetrik = () => useSyncExternalStore(abonniere, () => letzte) export const useLetzteMetrik = () => useSyncExternalStore(abonniere, () => letzte)
/** Nur für Tests: Messpunkt direkt einspeisen. */
export function metrikEinspeisen(m: MetrikPunkt | null) {
letzte = m
melde()
}
function setzeLage(neu: StromLage) { function setzeLage(neu: StromLage) {
if (neu !== lage) { if (neu !== lage) {
lage = neu lage = neu
@@ -59,30 +65,91 @@ function setzeLage(neu: StromLage) {
export function useStrom() { export function useStrom() {
const qc = useQueryClient() const qc = useQueryClient()
useEffect(() => { useEffect(() => {
const es = new EventSource("/api/stream") let quelle: EventSource | null = null
es.onopen = () => { let wiederTimer: number | undefined
setzeLage("live") let stilleTimer: number | undefined
// Was während einer Trennung passiert ist, hat der Strom nicht gemeldet. let letzteRegung = Date.now()
qc.invalidateQueries() let pause = PAUSE_ERST_MS
let beendet = false
function spaeterNeu() {
if (beendet) return
window.clearTimeout(wiederTimer)
wiederTimer = window.setTimeout(verbinden, pause)
pause = Math.min(pause * 2, PAUSE_HOECHST_MS)
} }
es.onerror = () => setzeLage("getrennt")
es.addEventListener("invalidate", (e) => { // Jedes Ereignis beweist, dass die Leitung lebt; bleibt es still, gilt sie als tot.
try { function lebt() {
const keys: string[] = JSON.parse((e as MessageEvent).data)?.keys ?? [] letzteRegung = Date.now()
for (const k of keys) qc.invalidateQueries({ queryKey: [k] }) window.clearTimeout(stilleTimer)
} catch { stilleTimer = window.setTimeout(() => {
/* ein kaputtes Ereignis reißt den Strom nicht */ quelle?.close()
setzeLage("getrennt")
spaeterNeu()
}, STILLE_MS)
}
function verbinden() {
window.clearTimeout(wiederTimer)
window.clearTimeout(stilleTimer)
quelle?.close()
const es = new EventSource("/api/stream")
quelle = es
es.onopen = () => {
pause = PAUSE_ERST_MS
setzeLage("live")
lebt()
// Was während einer Trennung passiert ist, hat der Strom nicht gemeldet.
qc.invalidateQueries()
} }
}) es.onerror = () => {
es.addEventListener("metrik", (e) => { setzeLage("getrennt")
try { if (es.readyState === EventSource.CLOSED) {
metrikEinspeisen(JSON.parse((e as MessageEvent).data) as MetrikPunkt) window.clearTimeout(stilleTimer)
} catch { spaeterNeu()
/* ein kaputter Messpunkt reißt den Strom nicht */ }
} }
}) es.addEventListener("invalidate", (e) => {
lebt()
try {
const keys: string[] = JSON.parse((e as MessageEvent).data)?.keys ?? []
for (const k of keys) qc.invalidateQueries({ queryKey: [k] })
} catch {
/* ein kaputtes Ereignis reißt den Strom nicht */
}
})
es.addEventListener("metrik", (e) => {
lebt()
try {
letzte = JSON.parse((e as MessageEvent).data) as MetrikPunkt
melde()
} catch {
/* ein kaputter Messpunkt reißt den Strom nicht */
}
})
}
// Netz wieder da oder Tab wieder vorn (Handys kappen Verbindungen im Hintergrund): nicht auf
// Pause oder Stille-Wächter warten, wenn der Strom aufgegeben hat oder seit Sekunden schweigt.
const wiederDa = () => {
if (beendet || document.visibilityState !== "visible") return
if (quelle?.readyState === EventSource.CLOSED || Date.now() - letzteRegung > 5_000) {
pause = PAUSE_ERST_MS
verbinden()
}
}
verbinden()
window.addEventListener("online", wiederDa)
document.addEventListener("visibilitychange", wiederDa)
return () => { return () => {
es.close() beendet = true
window.clearTimeout(wiederTimer)
window.clearTimeout(stilleTimer)
window.removeEventListener("online", wiederDa)
document.removeEventListener("visibilitychange", wiederDa)
quelle?.close()
setzeLage("getrennt") setzeLage("getrennt")
} }
}, [qc]) }, [qc])