feat(laeufe): Service, Abarbeitung, Kommentare und Archivschutz
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

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:
2026-09-25 16:56:33 +00:00
co-authored by Claude Opus 5
parent eec5de404a
commit c78a6ac1af
44 changed files with 7440 additions and 61 deletions
@@ -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>
);
}