"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { RefreshCw } from "lucide-react"; /** * Haelt die Abarbeitungsansicht aktuell (#013, Strom seit #033). * * Damit sehen zwei Browser am selben Lauf die Arbeit des anderen — der Fall aus * dem Auftrag: zwei Techniker teilen sich ein Onboarding. * * **Seit #033 ueber einen Strom**, nicht mehr alle zehn Sekunden. Die Datenbank * meldet die Aenderung, der Server schiebt sie weiter, und der Haken des * Kollegen steht in unter einer Sekunde auf dem anderen Schirm statt nach bis zu * zehn. Das Polling bleibt als **Rueckfall** — an derselben Adresse, mit * derselben Nutzlast. * * Fuenf Entscheidungen stecken darin: * * 1. **Pause bei verstecktem Tab.** Ein Lauf bleibt oft stundenlang offen im * Hintergrund. Der Strom wird dabei **geschlossen**, nicht nur ignoriert: * eine offene Verbindung je Hintergrundlasche ist genau das, was die * Obergrenze im Handler verhindern soll. * 2. **`router.refresh()` statt eigener Zustandsverwaltung.** Die Seite ist eine * Server-Komponente; ein Neuladen ihrer Daten ist genau das, was gebraucht * wird, und es gibt keine zweite Wahrheit im Browser. Aktualisiert wird nur, * wenn sich wirklich etwas geaendert hat — sonst wuerde die Seite bei jedem * Ereignis neu rendern und dabei Eingaben verlieren, an denen jemand tippt. * 3. **Steht ein generiertes Passwort auf der Seite, wird nicht von selbst * neu geladen**, sondern gefragt. Das Passwort entsteht bei jedem Aufbau der * Seite neu; ein Neuladen im Hintergrund tauschte es dem Techniker zwischen * Lesen und Einfuegen aus. Die Nutzlast traegt deshalb keine Befehle (siehe * `laufZustand`) — und diese Zusage waere nichts wert, wenn die * Aktualisierung stattdessen die ganze Seite neu bauen liesse. * 4. **Abbruch bei 401 und 404.** Eine abgelaufene Anmeldung oder ein * verschwundener Lauf sind kein Grund weiterzufragen. * 5. **Der Rueckfall wird einmal gemeldet, nicht bei jedem Versuch.** Sonst * schreibt eine Seite hinter einem puffernden Proxy im Minutentakt dieselbe * Zeile ins Protokoll der Konsole. */ /** * Was ein geaenderter Zustand ausloesen soll. * * Eine eigene Funktion, damit die Entscheidung geprueft werden kann: fuer eine * Client-Komponente gibt es in diesem Projekt bewusst keine DOM-Umgebung, und * eine Regel, die nur im Effekt steht, ist eine Regel ohne Test. */ export function reaktionAufAenderung(passwortSichtbar: boolean): "neu-laden" | "fragen" { return passwortSichtbar ? "fragen" : "neu-laden"; } /** `EventSource.CLOSED`, ohne im Test ein `EventSource` zu brauchen. */ export const STROM_GESCHLOSSEN = 2; /** * Ist ein Fehler am Strom das Ende – oder nur eine Unterbrechung? * * `EventSource` verbindet sich nach einem Abbruch **selbst** wieder; dann steht * `readyState` auf `CONNECTING`, und zurueckzufallen waere verfruehtes Aufgeben. * Endgueltig ist nur `CLOSED` — so antwortet der Browser etwa, wenn die Antwort * nicht `text/event-stream` ist. Genau das schickt der Handler bei erreichter * Obergrenze, und genau dann soll gepollt werden. */ export function sollZurueckfallen(readyState: number): boolean { return readyState === STROM_GESCHLOSSEN; } export function LaufAktualisierung({ laufId, passwortSichtbar = false, }: { laufId: string; /** Auf der Seite steht ein gefuelltes `{{generiertes_passwort}}`. */ passwortSichtbar?: boolean; }) { const router = useRouter(); /* * Der zuletzt gesehene Zustand als Referenz und nicht als State: ein * `useState` daraus loeste bei jedem Abruf ein Rendern aus — auch dann, wenn * sich nichts geaendert hat, und genau das soll es nicht. */ const letzterStand = useRef(null); const [neues, setzeNeues] = useState(false); /** * Ein gelesener Stand: vergleichen, und nur bei echter Aenderung reagieren. * * Dieselbe Stelle fuer Strom und Polling. Waere der Vergleich zweimal * geschrieben, koennten die beiden Wege unterschiedlich reagieren — und der * Rueckfall waere dann kein Rueckfall, sondern ein zweites Verhalten. */ const standVerarbeiten = useCallback( (stand: string): void => { if (letzterStand.current !== null && letzterStand.current !== stand) { if (reaktionAufAenderung(passwortSichtbar) === "fragen") setzeNeues(true); else router.refresh(); } letzterStand.current = stand; }, [passwortSichtbar, router], ); useEffect(() => { const adresse = `/api/laeufe/${laufId}/zustand`; let eingestellt = false; let quelle: EventSource | undefined; let zeitgeber: ReturnType | undefined; let polltJetzt = false; let rueckfallGemeldet = false; /* ---------------- Polling: der Rueckfall ---------------- */ async function holen(): Promise { if (eingestellt || document.visibilityState === "hidden") { plane(); return; } try { const antwort = await fetch(adresse, { cache: "no-store" }); if (antwort.status === 401 || antwort.status === 404) { eingestellt = true; return; } if (antwort.ok) standVerarbeiten(JSON.stringify(await antwort.json())); } catch { // Netz weg, Rechner im Standby: einfach beim naechsten Mal wieder. } plane(); } function plane(): void { if (eingestellt || !polltJetzt) return; zeitgeber = setTimeout(() => void holen(), 10_000); } function aufPollingUmstellen(grund: string): void { if (eingestellt || polltJetzt) return; polltJetzt = true; stromSchliessen(); if (!rueckfallGemeldet) { rueckfallGemeldet = true; // Einmal, nicht bei jedem Versuch – siehe Kopfkommentar. console.info(`Live-Aktualisierung nicht moeglich (${grund}), es wird gepollt.`); } void holen(); } /* ---------------- Strom: der Normalfall ---------------- */ function stromSchliessen(): void { quelle?.close(); quelle = undefined; } function stromOeffnen(): void { if (eingestellt || polltJetzt || quelle || typeof EventSource === "undefined") return; const neu = new EventSource(adresse); quelle = neu; neu.addEventListener("zustand", (ereignis) => { standVerarbeiten((ereignis as MessageEvent).data); }); // Der Server sagt, dass es nichts mehr zu sehen gibt – nicht weiterfragen. neu.addEventListener("ende", () => { eingestellt = true; stromSchliessen(); }); neu.addEventListener("error", () => { if (sollZurueckfallen(neu.readyState)) aufPollingUmstellen("Strom geschlossen"); // Sonst: `EventSource` verbindet selbst wieder, hier ist nichts zu tun. }); } /* ---------------- Sichtbarkeit ---------------- */ const beiSichtbarkeit = (): void => { if (eingestellt) return; if (document.visibilityState === "hidden") { // Verbindung wirklich freigeben, nicht bloss ignorieren. stromSchliessen(); if (zeitgeber) clearTimeout(zeitgeber); return; } /* * Zurueck im Vordergrund: erst den Stand holen, dann wieder zuhoeren. * Waehrend die Lasche weg war, kam keine Meldung an — ohne diesen Abruf * stuende der alte Stand da, bis das naechste Mal jemand etwas aendert. */ if (polltJetzt) void holen(); else { stromOeffnen(); void einmalHolen(); } }; /** Ein einzelner Abruf, ohne das Polling einzuschalten. */ async function einmalHolen(): Promise { try { const antwort = await fetch(adresse, { cache: "no-store" }); if (antwort.status === 401 || antwort.status === 404) { eingestellt = true; stromSchliessen(); return; } if (antwort.ok) standVerarbeiten(JSON.stringify(await antwort.json())); } catch { // Der Strom liefert den Stand ohnehin gleich mit. } } if (typeof EventSource === "undefined") aufPollingUmstellen("kein EventSource"); else stromOeffnen(); document.addEventListener("visibilitychange", beiSichtbarkeit); return () => { eingestellt = true; stromSchliessen(); if (zeitgeber) clearTimeout(zeitgeber); document.removeEventListener("visibilitychange", beiSichtbarkeit); }; }, [laufId, standVerarbeiten]); // Ohne Passwort auf der Seite ist die Wirkung unsichtbar: sie erneuert sich. if (!neues) return null; return (
Jemand anderes hat an diesem Lauf etwas geändert. Beim Neuladen entsteht ein{" "} neues Passwort — kopiere das angezeigte vorher, wenn du es noch brauchst.
); }