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
+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
// 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 { useQueryClient } from "@tanstack/react-query"
@@ -27,6 +34,11 @@ export interface MetrikPunkt {
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 letzte: MetrikPunkt | null = null
const hoerer = new Set<() => void>()
@@ -43,12 +55,6 @@ export const stromVerbunden = () => lage === "live"
export const useStromLage = () => useSyncExternalStore(abonniere, () => lage)
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) {
if (neu !== lage) {
lage = neu
@@ -59,30 +65,91 @@ function setzeLage(neu: StromLage) {
export function useStrom() {
const qc = useQueryClient()
useEffect(() => {
const es = new EventSource("/api/stream")
es.onopen = () => {
setzeLage("live")
// Was während einer Trennung passiert ist, hat der Strom nicht gemeldet.
qc.invalidateQueries()
let quelle: EventSource | null = null
let wiederTimer: number | undefined
let stilleTimer: number | undefined
let letzteRegung = Date.now()
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) => {
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 */
// Jedes Ereignis beweist, dass die Leitung lebt; bleibt es still, gilt sie als tot.
function lebt() {
letzteRegung = Date.now()
window.clearTimeout(stilleTimer)
stilleTimer = window.setTimeout(() => {
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.addEventListener("metrik", (e) => {
try {
metrikEinspeisen(JSON.parse((e as MessageEvent).data) as MetrikPunkt)
} catch {
/* ein kaputter Messpunkt reißt den Strom nicht */
es.onerror = () => {
setzeLage("getrennt")
if (es.readyState === EventSource.CLOSED) {
window.clearTimeout(stilleTimer)
spaeterNeu()
}
}
})
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 () => {
es.close()
beendet = true
window.clearTimeout(wiederTimer)
window.clearTimeout(stilleTimer)
window.removeEventListener("online", wiederDa)
document.removeEventListener("visibilitychange", wiederDa)
quelle?.close()
setzeLage("getrennt")
}
}, [qc])