feat(laeufe): Service, Abarbeitung, Kommentare und Archivschutz
Ein Lauf wird vorbereitet, abgearbeitet, abgeschlossen und archiviert. Der Archiv-Schreibschutz sitzt in genau einer Funktion und wird vor jeder Aenderung an Kopfdaten, Schrittergebnis und Kommentar gerufen; abgeschlossen bleibt aenderbar, archiviert nicht. Die Abarbeitungsansicht speichert jeden Schritt einzeln, frischt sich alle zehn Sekunden auf und pausiert im Hintergrund. Sie traegt in der Polling-Antwort keine Befehle und laedt sich auch nicht von selbst neu, solange ein generiertes Passwort auf der Seite steht: beides tauschte es dem Techniker zwischen Lesen und Einfuegen aus. Der unabhaengige Review fand zwei Blocker: ein Befehl konnte zwei verschiedene Passwoerter tragen (Konto bekommt A, Benutzer erfaehrt B), und „nicht zutreffend“ liess sich nicht zuruecknehmen, waehrend die Oberflaeche behauptete, nichts sei gewaehlt. Beides behoben und in Tests festgehalten. Closes #13 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #27.
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Holt alle zehn Sekunden den Zustand des Laufs (#013).
|
||||
*
|
||||
* Damit sehen zwei Browser am selben Lauf die Arbeit des anderen — der Fall aus
|
||||
* dem Auftrag: zwei Techniker teilen sich ein Onboarding.
|
||||
*
|
||||
* Vier Entscheidungen stecken darin:
|
||||
*
|
||||
* 1. **Pause bei verstecktem Tab.** Ein Lauf bleibt oft stundenlang offen im
|
||||
* Hintergrund; ohne Pause waeren das 360 Abfragen je Stunde und Tab, fuer
|
||||
* niemanden.
|
||||
* 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 alle zehn
|
||||
* Sekunden 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 Antwort des Pollings traegt deshalb keine
|
||||
* Befehle (siehe `laufZustand`) — und diese Zusage waere nichts wert, wenn
|
||||
* das Polling stattdessen die ganze Seite neu bauen liesse.
|
||||
* 4. **Abbruch bei 401 und 404.** Eine abgelaufene Anmeldung oder ein
|
||||
* verschwundener Lauf sind kein Grund weiterzufragen.
|
||||
*/
|
||||
/**
|
||||
* 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";
|
||||
}
|
||||
|
||||
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<string | null>(null);
|
||||
const [neues, setzeNeues] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let abgebrochen = false;
|
||||
let zeitgeber: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
async function holen(): Promise<void> {
|
||||
if (abgebrochen || document.visibilityState === "hidden") {
|
||||
plane();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const antwort = await fetch(`/api/laeufe/${laufId}/zustand`, { cache: "no-store" });
|
||||
if (antwort.status === 401 || antwort.status === 404) {
|
||||
abgebrochen = true;
|
||||
return;
|
||||
}
|
||||
if (antwort.ok) {
|
||||
const stand = JSON.stringify(await antwort.json());
|
||||
if (letzterStand.current !== null && letzterStand.current !== stand) {
|
||||
if (reaktionAufAenderung(passwortSichtbar) === "fragen") setzeNeues(true);
|
||||
else router.refresh();
|
||||
}
|
||||
letzterStand.current = stand;
|
||||
}
|
||||
} catch {
|
||||
// Netz weg, Rechner im Standby: einfach beim naechsten Mal wieder.
|
||||
}
|
||||
plane();
|
||||
}
|
||||
|
||||
function plane(): void {
|
||||
if (abgebrochen) return;
|
||||
zeitgeber = setTimeout(() => void holen(), 10_000);
|
||||
}
|
||||
|
||||
void holen();
|
||||
// Kommt der Tab zurueck, sofort fragen statt bis zu zehn Sekunden zu warten.
|
||||
const beiSichtbarkeit = () => {
|
||||
if (document.visibilityState === "visible") void holen();
|
||||
};
|
||||
document.addEventListener("visibilitychange", beiSichtbarkeit);
|
||||
|
||||
return () => {
|
||||
abgebrochen = true;
|
||||
if (zeitgeber) clearTimeout(zeitgeber);
|
||||
document.removeEventListener("visibilitychange", beiSichtbarkeit);
|
||||
};
|
||||
}, [laufId, router, passwortSichtbar]);
|
||||
|
||||
// Ohne Passwort auf der Seite ist die Wirkung unsichtbar: sie erneuert sich.
|
||||
if (!neues) return null;
|
||||
|
||||
return (
|
||||
<div className="sticky top-2 z-10 mb-4 flex flex-wrap items-center gap-3 rounded-karte border border-akzent/40 bg-akzent-flaeche p-3 text-sm">
|
||||
<span className="min-w-0 flex-1">
|
||||
Jemand anderes hat an diesem Lauf etwas geändert. Beim Neuladen entsteht ein{" "}
|
||||
<strong>neues Passwort</strong> — kopiere das angezeigte vorher, wenn du es noch brauchst.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setzeNeues(false);
|
||||
router.refresh();
|
||||
}}
|
||||
className="flex min-h-11 shrink-0 items-center gap-2 rounded-pille bg-akzent px-4 font-semibold text-auf-akzent md:min-h-10"
|
||||
>
|
||||
<RefreshCw className="size-4" strokeWidth={2} aria-hidden="true" />
|
||||
Neu laden
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user