Aufraeumarbeiten: ESLint, DOM-Verschachtelung, eigene Testwehr

ESLint hatte keine Konfiguration — das Skript "npm run lint" pruefte nichts.
Jetzt Flat Config ohne Formatierungsregeln, dafuer mit den Regeln, die die
Fehler dieses Projekts gemeldet haetten. Er hat gleich zwei gefunden, die
niemand gesucht hatte.

Das Badge rendert ein span statt eines div. Damit ist die Meldung
"div cannot be a descendant of p" weg, die seit Phase 3 auf der Einsatzseite
stand — sie ist der klassische Ausloeser fuer einen Hydration-Fehler.

Die E2E-Tests laufen gegen eine eigene Wehr. Nach fuenf Phasen standen in der
Beispielwehr 120 Atemschutz-Ueberwachungen statt einer und 115 Geraete statt
45; bei jedem Lauf aenderten sich alle Bildschirmfotos. Der Bestand ist einmal
zurueckgesetzt, npm run db:test-reset baut die Testwehr neu auf.

Behoben nebenbei:
- Die Fehlerflaeche der Karte konnte nie etwas anzeigen.
- Zwei useMemo der Lagekarte rechneten bei jedem Rendern neu.
- tests/e2e war vom Typecheck ausgenommen und liess einen doppelt
  deklarierten Bezeichner durch.

Closes #10
Closes #11
Closes #12

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-04 13:24:48 +02:00
co-authored by Claude Opus 5
parent 8081131eb7
commit 7196c7110c
94 changed files with 1447 additions and 428 deletions
@@ -3,7 +3,7 @@
import * as React from "react";
import Link from "next/link";
import { BellRing, Copy, Send, Trash2, TriangleAlert } from "lucide-react";
import { EreignisTyp, Kanal } from "@prisma/client";
import { Kanal } from "@prisma/client";
import type { inferRouterOutputs } from "@trpc/server";
import { trpc } from "@/lib/trpc/client";
import type { AppRouter } from "@/server/routers/_app";
+10 -1
View File
@@ -81,7 +81,16 @@ export function Lagekarte({
onError: (fehler) => setMeldung(fehler.message),
});
const elemente = karte.data?.elemente ?? [];
/**
* Die Elemente der Lagekarte.
*
* Das `?? []` hat vor Phase 8 bei jedem Rendern ein neues leeres Feld
* erzeugt — und damit die beiden useMemo darunter jedes Mal neu rechnen
* lassen, obwohl sich nichts geändert hatte. Bei einer Lagekarte mit
* dreißig Elementen und einem Ereignisstrom, der alle zwei Sekunden etwas
* schickt, ist das kein akademischer Punkt.
*/
const elemente = React.useMemo(() => karte.data?.elemente ?? [], [karte.data?.elemente]);
const punkte = React.useMemo<KartenPunkt[]>(() => {
const liste: KartenPunkt[] = [];
+9
View File
@@ -63,6 +63,14 @@ export default async function FahrzeugSeite({ params }: { params: Promise<{ id:
if (!fahrzeug) notFound();
/*
* eslint-disable-next-line react/jsx-key -- Fehlalarm: Diese JSX-Elemente
* sind nicht die Kinder einer Liste, sondern die zweiten Glieder von
* Wertepaaren. Gerendert werden sie unten in einer map, deren Wrapper den
* key traegt (`<div key={bezeichnung}>`). Ein key an dieser Stelle waere
* wirkungslos und wuerde nur verschleiern, wo er wirklich sitzt.
*/
/* eslint-disable react/jsx-key */
const angaben: Array<[string, React.ReactNode]> = [
["Typ nach DIN", fahrzeug.dinTyp ?? "—"],
["Kennzeichen", <span className="tabellenzahlen">{fahrzeug.kennzeichen ?? "—"}</span>],
@@ -88,6 +96,7 @@ export default async function FahrzeugSeite({ params }: { params: Promise<{ id:
],
["Standort", fahrzeug.standort?.name ?? "—"],
];
/* eslint-enable react/jsx-key */
const alleRaeume = [
...fahrzeug.geraeteraeume,
+9
View File
@@ -104,6 +104,14 @@ export default async function GeraetSeite({ params }: { params: Promise<{ id: st
const { geraet, verlauf, pruefungen, maengel, termine } = daten;
const qr = await qrAlsDataUri(geraet.inventarnummer, 320);
/*
* eslint-disable-next-line react/jsx-key -- Fehlalarm: Diese JSX-Elemente
* sind nicht die Kinder einer Liste, sondern die zweiten Glieder von
* Wertepaaren. Gerendert werden sie unten in einer map, deren Wrapper den
* key traegt (`<div key={bezeichnung}>`). Ein key an dieser Stelle waere
* wirkungslos und wuerde nur verschleiern, wo er wirklich sitzt.
*/
/* eslint-disable react/jsx-key */
const angaben: Array<[string, React.ReactNode]> = [
["Inventarnummer", <span className="tabellenzahlen">{geraet.inventarnummer}</span>],
["Gerätetyp", geraet.geraetetyp?.name ?? "—"],
@@ -129,6 +137,7 @@ export default async function GeraetSeite({ params }: { params: Promise<{ id: st
["Lebensdauer bis", <FristAnzeige datum={geraet.lebensdauerBis} vorwarnTage={90} />],
["Prüfvorschrift", geraet.geraetetyp?.pruefvorschrift ?? "—"],
];
/* eslint-enable react/jsx-key */
return (
<div className="space-y-6">
@@ -118,7 +118,6 @@ export function Scanner() {
{fehler && <Fehlermeldung meldung={fehler} />}
<div className="overflow-hidden rounded-lg border border-[var(--border)] bg-black">
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
ref={videoRef}
className="aspect-video w-full object-cover"
@@ -8,13 +8,6 @@ import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
+14 -1
View File
@@ -78,7 +78,17 @@ export function KartenAnsicht({
breitengrad: number;
laengengrad: number;
} | null>(null);
const [meldung, setMeldung] = React.useState<string | null>(null);
/**
* Was schiefging.
*
* Bis Phase 8 stand hier ein Zustand, der nie gesetzt wurde — die
* Fehlerfläche darunter konnte also nichts anzeigen, und eine gescheiterte
* Abfrage sah aus wie „keine Hydranten vorhanden". Gefunden hat das der
* neue Linter.
*
* Jetzt kommt die Meldung aus den Abfragen selbst. Ein eigener Zustand
* dafür wäre eine zweite Wahrheit neben der von React Query.
*/
const hydranten = trpc.hydrant.liste.useQuery({
suche: suche || undefined,
@@ -137,6 +147,9 @@ export function KartenAnsicht({
? objekte.data?.find((eintrag) => eintrag.id === gewaehlt.id)
: undefined;
const meldung =
hydranten.error?.message ?? objekte.error?.message ?? kennzahlen.error?.message ?? null;
return (
<div className="space-y-4" data-testid="kartenansicht">
{meldung && <Fehlermeldung meldung={meldung} kennung="kartenfehler" />}
@@ -19,13 +19,6 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
import { KATEGORIE_TEXT } from "@/app/(app)/karte/objekte/objekt-liste";
@@ -232,7 +225,7 @@ export function ObjektDetail({ id, darfBearbeiten }: { id: string; darfBearbeite
<div>
<CardTitle>Ansprechpartner</CardTitle>
<CardDescription>
Wer nachts ans Telefon geht, steht mit „jederzeit" markiert.
Wer nachts ans Telefon geht, steht mit „jederzeit“ markiert.
</CardDescription>
</div>
{darfBearbeiten && (
+9
View File
@@ -65,6 +65,14 @@ export default async function MitgliedSeite({ params }: { params: Promise<{ id:
if (!person) notFound();
/*
* eslint-disable-next-line react/jsx-key -- Fehlalarm: Diese JSX-Elemente
* sind nicht die Kinder einer Liste, sondern die zweiten Glieder von
* Wertepaaren. Gerendert werden sie unten in einer map, deren Wrapper den
* key traegt (`<div key={bezeichnung}>`). Ein key an dieser Stelle waere
* wirkungslos und wuerde nur verschleiern, wo er wirklich sitzt.
*/
/* eslint-disable react/jsx-key */
const angaben: Array<[string, React.ReactNode]> = [
["Mitgliedsnummer", <span className="tabellenzahlen">{person.mitgliedsnummer ?? "—"}</span>],
["Dienstgrad", DIENSTGRAD_TEXT[person.dienstgrad] ?? person.dienstgrad],
@@ -79,6 +87,7 @@ export default async function MitgliedSeite({ params }: { params: Promise<{ id:
person.strasse ? `${person.strasse}, ${person.plz ?? ""} ${person.ort ?? ""}`.trim() : "—",
],
];
/* eslint-enable react/jsx-key */
return (
<div className="space-y-6">
@@ -278,7 +278,7 @@ export function FristenDashboard({
Offene Termine
{fenster && (
<span className="ml-2 text-sm font-normal text-[var(--muted-foreground)]">
gefiltert auf „{fenster.replace(/_/g, " ")}"
gefiltert auf „{fenster.replace(/_/g, " ")}“
</span>
)}
</CardTitle>
@@ -133,7 +133,7 @@ export function ChecklistenEditor({
<CardDescription>
Der Prüfwizard fragt die Punkte in dieser Reihenfolge ab. Pflichtpunkte müssen
beantwortet sein, bevor sich eine Prüfung abschließen lässt — und ein durchgefallener
Pflichtpunkt bedeutet „nicht bestanden".
Pflichtpunkt bedeutet „nicht bestanden“.
</CardDescription>
</CardHeader>
@@ -222,7 +222,7 @@ export function ChecklistenEditor({
<CardHeader>
<CardTitle>Prüfpunkt ergänzen</CardTitle>
<CardDescription>
Bei einem Messwert entscheidet der zulässige Bereich über „in Ordnung" — gerechnet
Bei einem Messwert entscheidet der zulässige Bereich über „in Ordnung“ — gerechnet
wird das auf dem Server, nicht im Browser.
</CardDescription>
</CardHeader>
+1 -1
View File
@@ -29,7 +29,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
import { Markdown } from "@/components/ki/markdown";
import { WIKI_KATEGORIE_TEXT } from "@/lib/ki/texte";
-1
View File
@@ -108,7 +108,6 @@ export function Diktat({ entitaet, entitaetId, onUebernehmen }: DiktatProps) {
}, 500);
return () => clearInterval(zeitgeber);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [zustand, moeglichkeiten.data?.maxSekunden]);
function zuruecksetzen() {
-1
View File
@@ -98,7 +98,6 @@ export function QrLeser({
return (
<div className="space-y-3">
<div className="overflow-hidden rounded-lg border border-[var(--border)] bg-black">
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video ref={videoRef} className="aspect-video w-full object-cover" muted playsInline />
</div>
+15 -2
View File
@@ -31,11 +31,24 @@ const badgeVarianten = cva(
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof badgeVarianten> {}
/**
* Ein `<span>`, kein `<div>`.
*
* Bis Phase 8 war es ein `<div>`, und React meldete auf der Einsatzseite seit
* Phase 3: „In HTML, <div> cannot be a descendant of <p>." Dort stehen die
* Abzeichen für Kategorie und Status in einer Zeile Fließtext — und ein
* Blockelement in einem Absatz ist ungültiges HTML. Der Browser schließt den
* Absatz von sich aus davor, baut also einen anderen Baum als React erwartet,
* und das ist der klassische Auslöser für einen Hydration-Fehler.
*
* Ein Abzeichen ist ohnehin Fließtext-Inhalt. Die Darstellung ändert sich
* nicht: `inline-flex` macht es auch als span zu einer Zeile mit Innenabstand.
*/
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVarianten({ variant }), className)} {...props} />;
return <span className={cn(badgeVarianten({ variant }), className)} {...props} />;
}
export { Badge, badgeVarianten };
-1
View File
@@ -104,7 +104,6 @@ class EchtesNtfy implements NtfyVersand {
* Aufruf oder — schlimmer — ein Titel voller Fragezeichen.
*/
function alsAsciiKopf(text: string): string {
// eslint-disable-next-line no-control-regex
if (/^[\x00-\x7F]*$/.test(text)) return text;
return `=?UTF-8?B?${Buffer.from(text, "utf8").toString("base64")}?=`;
}