feat(search): pg_trgm-Suchindex + Backend-Endpunkt statt Client-Filter
CI / backend-tests (push) Failing after 2m9s
CI / frontend-build (push) Successful in 21s

Command Palette lud bisher beim Öffnen ALLE Objekte und filterte nur
clientseitig per .includes() - unscharf und würde bei mehr Objekten
langsam werden. Jetzt: GIN-Trigram-Index auf objekt.name/code (Migration
0023), neuer GET /suche?q=-Endpunkt (Ähnlichkeits-Ranking per pg_trgm
similarity()), Frontend debounced (250ms) statt vollständiger Objektliste.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-08 00:44:03 +02:00
co-authored by Claude Sonnet 5
parent c608f06806
commit 307914ccf7
7 changed files with 147 additions and 18 deletions
+41 -18
View File
@@ -3,7 +3,12 @@ import { useNavigate } from "react-router-dom";
import { apiRequest } from "../api/client";
import { useAuth } from "../auth/AuthContext";
import type { Objekt } from "../api/types";
interface SuchTreffer {
id: number;
code: string;
name: string;
}
interface Eintrag {
id: string;
@@ -22,7 +27,7 @@ export function CommandPalette() {
const { istVerantwortlich, istAdmin } = useAuth();
const [offen, setOffen] = useState(false);
const [suche, setSuche] = useState("");
const [objekte, setObjekte] = useState<Objekt[]>([]);
const [objektTreffer, setObjektTreffer] = useState<SuchTreffer[]>([]);
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
@@ -44,13 +49,34 @@ export function CommandPalette() {
}, []);
useEffect(() => {
if (offen && objekte.length === 0) {
apiRequest<Objekt[]>("/objekte")
.then(setObjekte)
.catch(() => undefined);
if (!offen) {
setSuche("");
setObjektTreffer([]);
}
if (!offen) setSuche("");
}, [offen, objekte.length]);
}, [offen]);
// Debounced Backend-Suche statt kompletter Objektliste + Client-Filter
// (SEARCH-001) - vorher wurden ALLE Objekte beim Öffnen geladen und nur
// clientseitig gefiltert, was bei vielen Objekten langsam/unscharf würde.
useEffect(() => {
const trimmed = suche.trim();
if (trimmed.length < 2) {
setObjektTreffer([]);
return;
}
let abgebrochen = false;
const timer = setTimeout(() => {
apiRequest<SuchTreffer[]>(`/suche?q=${encodeURIComponent(trimmed)}`)
.then((treffer) => {
if (!abgebrochen) setObjektTreffer(treffer);
})
.catch(() => undefined);
}, 250);
return () => {
abgebrochen = true;
clearTimeout(timer);
};
}, [suche]);
const aktionen = useMemo<Eintrag[]>(() => {
const liste: Eintrag[] = [
@@ -83,19 +109,16 @@ export function CommandPalette() {
return liste;
}, [navigate, istVerantwortlich, istAdmin]);
const objektTreffer = useMemo<Eintrag[]>(() => {
if (suche.trim().length === 0) return [];
const s = suche.toLowerCase();
return objekte
.filter((o) => o.name.toLowerCase().includes(s) || o.code.toLowerCase().includes(s))
.slice(0, 8)
.map((o) => ({
const objektEintraege = useMemo<Eintrag[]>(
() =>
objektTreffer.map((o) => ({
id: `objekt-${o.id}`,
label: o.name,
hinweis: o.code,
gehe: () => navigate(`/akte/objekt/${o.id}`),
}));
}, [suche, objekte, navigate]);
})),
[objektTreffer, navigate]
);
const gefilterteAktionen = useMemo(() => {
if (suche.trim().length === 0) return aktionen;
@@ -105,7 +128,7 @@ export function CommandPalette() {
if (!offen) return null;
const treffer = [...objektTreffer, ...gefilterteAktionen];
const treffer = [...objektEintraege, ...gefilterteAktionen];
function auswaehlen(eintrag: Eintrag) {
eintrag.gehe();