Search names first, and only fall back to job titles

"Winkler M" returned four people, two of whom are not called M: Karin
Winkler is a Montagemitarbeiterin and Katharina Winkler a
Maschinenbedienerin. The job title was searched with the same weight as
the name, so a single letter matched the start of a job word just as
readily as the start of a first name.

Searching job titles is worth keeping — "dreher" finding the CNC-Dreher
is useful. So the search is now tiered: names alone first, and the job
title joins in only when the names return nothing at all. A minimum word
length would have been the simpler rule, but any threshold is a guess;
this one is decided by the data in front of it.

Checked against the live data: "winkler m" gives Martin and Magdalena,
"winkler h" Hannah, "dreher" and "montage" still find their trades, and
"winkler montage" finds Karin Winkler — no name matches both words, so
the fallback does what was meant.

When the fallback runs, the result line says so. Without that, a list of
people whose names look nothing like the query reads as though the
search invented them.

Costs one small count query, and only when text was typed.

Not verified with next build: a dev server from an earlier session is
holding .next, and the user is testing in it. tsc, eslint and 297 tests
are green, and the search itself was run against the database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 21:13:41 +02:00
parent 33d053ce75
commit 9308096754

View File

@@ -50,7 +50,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
.map((s) => s.trim()) .map((s) => s.trim())
.filter((s): s is EmploymentStatus => (["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const).includes(s as EmploymentStatus)); .filter((s): s is EmploymentStatus => (["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const).includes(s as EmploymentStatus));
const { orgMaps, employees, count, placements } = await withUser(await currentUserId(), async (tx) => { const { orgMaps, employees, count, placements, ueberPosition } = await withUser(await currentUserId(), async (tx) => {
// Die Referenzdaten zuerst: der Bereichsfilter braucht den Teilbaum. // Die Referenzdaten zuerst: der Bereichsfilter braucht den Teilbaum.
// „Produktion" meint die Abteilungen und Teams darunter — in der Einheit // „Produktion" meint die Abteilungen und Teams darunter — in der Einheit
// selbst sitzt nur die Bereichsleitung. // selbst sitzt nur die Bereichsleitung.
@@ -62,7 +62,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
// vorher brauchte es zwei getrennte Select-Formen, weil der Typparser der // vorher brauchte es zwei getrennte Select-Formen, weil der Typparser der
// API-Schicht einen bedingt zusammengesetzten Select-String nicht // API-Schicht einen bedingt zusammengesetzten Select-String nicht
// auflösen konnte. // auflösen konnte.
const base = () => { const base = (nurNamen: boolean) => {
let q = tx.selectFrom("employees"); let q = tx.selectFrom("employees");
if (unitFilter) { if (unitFilter) {
@@ -91,44 +91,35 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
if (istPersonalnummer(term)) { if (istPersonalnummer(term)) {
q = q.where("personnel_number", "=", Number(term)); q = q.where("personnel_number", "=", Number(term));
} else { } else {
// Wortweise statt am Stück, und **jedes** Wort muss irgendwo // ── Wie hier gesucht wird ──────────────────────────────────
// treffen.
// //
// Vorher wurde der ganze Begriff gegen jedes Feld einzeln geprüft. // **Wortweise, Reihenfolge egal.** Jedes Wort muss treffen, aber
// „Michael Winkler" stand damit in keinem Feld — weder Vor- noch // nicht in einer bestimmten Ordnung: „Winkler micha" und „micha
// Nachname enthalten die Zeichenkette — und die Suche kam auf null // Winkler" führen beide zu Michaela Winkler. Am Stück gesucht stand
// Treffer, obwohl es vierzehn Winkler gibt. Das las sich wie „die // „Michael Winkler" in keinem einzelnen Feld und ergab null Treffer,
// Person gibt es nicht". // obwohl es vierzehn Winkler gibt.
// //
// Die Zerlegung macht die Reihenfolge nebensächlich: „Winkler // **Am Wortanfang, nicht mittendrin.** Als Teilzeichenkette traf ein
// micha" und „micha Winkler" führen beide zu Michaela Winkler. Das // „H" auf T-h-omas und Kat-h-arina — bei „Winkler H" kamen alle
// ist mehr, als die Rückmeldung verlangte — sie bat um die // sieben Winkler zurück. Trennzeichen zählen als Wortgrenze, damit
// umgekehrte Reihenfolge —, aber eine Suche, bei der man sich die // „dreher" auch „CNC-Dreher:in" findet.
// richtige Reihenfolge merken muss, hat dasselbe Problem nur
// gespiegelt.
// //
// Verglichen wird gegen den zusammengesetzten Namen, weil genau // **Namen vor Positionen.** Die Position mitzudurchsuchen ist
// darauf der Trigramm-Index liegt (idx_employees_name_trgm). // nützlich („dreher"), darf aber eine Namenssuche nicht verwässern:
// Getrennte Felder hätten ihn ungenutzt gelassen. // bei „Winkler M" tauchten sonst Karin Winkler (Montagemitarbeiterin)
// Jedes Wort trifft am **Wortanfang**, nicht irgendwo mittendrin. // und Katharina Winkler (Maschinenbedienerin) auf, weil ihre
// Position mit M beginnt. Deshalb wird zuerst nur über die Namen
// gesucht; nur wenn das *nichts* findet, kommt die Position dazu.
// Eine feste Mindestlänge fürs Wort wäre die einfachere Regel, aber
// jede Grenze wäre geraten — diese hier ergibt sich aus den Daten.
// //
// Vorher wurde jedes Wort als Teilzeichenkette gesucht. Bei „Winkler // Der Trigramm-Index auf dem zusammengesetzten Namen greift bei
// H" traf das „H" auf T-h-omas, Kat-h-arina und CNC-Dre-h-er:in — // diesem Ausdruck nicht mehr. Bei knapp neunhundert Zeilen liest
// die Suche gab alle sieben Winkler zurück, obwohl genau eine Hannah // Postgres die Tabelle in wenigen Millisekunden; ein Index auf
// heisst. Je kürzer die Eingabe, desto unbrauchbarer wurde sie, und // demselben Ausdruck holt das zurück, sobald das nicht mehr stimmt.
// ein Anfangsbuchstabe ist die kürzeste sinnvolle Eingabe überhaupt. const heuhaufen = nurNamen
// ? sql<string>`translate(lower(first_name || ' ' || last_name), '-/:.,', ' ')`
// Gesucht wird über Vorname, Nachname und Position zusammen, wobei : sql<string>`translate(lower(first_name || ' ' || last_name || ' ' || job_title), '-/:.,', ' ')`;
// Trennzeichen als Wortgrenze gelten: „dreher" findet damit auch
// „CNC-Dreher:in". Ein Wort trifft, wenn der Heuhaufen damit beginnt
// oder ein Leerzeichen davorsteht.
//
// Der Trigramm-Index auf dem zusammengesetzten Namen greift hier
// nicht mehr — das ist der Preis. Bei knapp neunhundert Zeilen liest
// Postgres die Tabelle in wenigen Millisekunden; die Genauigkeit ist
// das wert, und bei Bedarf trägt ein Index auf demselben Ausdruck
// das später wieder.
const heuhaufen = sql<string>`translate(lower(first_name || ' ' || last_name || ' ' || job_title), '-/:.,', ' ')`;
q = q.where((eb) => q = q.where((eb) =>
eb.and( eb.and(
// Als Parameter gebunden, nicht in die Abfrage geschrieben. // Als Parameter gebunden, nicht in die Abfrage geschrieben.
@@ -150,8 +141,17 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
return q; return q;
}; };
// Erst nachsehen, ob die Namen allein etwas hergeben. Nur wenn nicht,
// wird die Position mitgesucht — eine zusätzliche, sehr kleine Abfrage,
// und nur bei einer Textsuche.
const sucheNachNamen = Boolean(params.q) && !istPersonalnummer(params.q!.trim());
const namensTreffer = sucheNachNamen
? Number((await base(true).select(({ fn }) => fn.countAll<string>().as("anzahl")).executeTakeFirst())?.anzahl ?? 0)
: 0;
const nurNamen = sucheNachNamen && namensTreffer > 0;
const [rows, total] = await Promise.all([ const [rows, total] = await Promise.all([
base() base(nurNamen)
.select([ .select([
"id", "id",
"first_name", "first_name",
@@ -173,7 +173,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
.limit(PAGE_SIZE) .limit(PAGE_SIZE)
.offset((page - 1) * PAGE_SIZE) .offset((page - 1) * PAGE_SIZE)
.execute(), .execute(),
base() base(nurNamen)
.select(({ fn }) => fn.countAll<string>().as("anzahl")) .select(({ fn }) => fn.countAll<string>().as("anzahl"))
.executeTakeFirst(), .executeTakeFirst(),
]); ]);
@@ -182,7 +182,15 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
// Seite, nicht für den ganzen Bestand. // Seite, nicht für den ganzen Bestand.
const placements = await loadPlacements(tx, { asOf: today, employeeIds: rows.map((e) => e.id) }); const placements = await loadPlacements(tx, { asOf: today, employeeIds: rows.map((e) => e.id) });
return { orgMaps, employees: rows, count: Number(total?.anzahl ?? 0), placements }; return {
orgMaps,
employees: rows,
count: Number(total?.anzahl ?? 0),
placements,
// Für den Hinweis über der Liste: wurde nach Namen gesucht, und hat es
// gereicht?
ueberPosition: sucheNachNamen && !nurNamen,
};
}); });
const totalPages = Math.max(1, Math.ceil(count / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(count / PAGE_SIZE));
@@ -192,7 +200,14 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
<Suspense> <Suspense>
<EmployeeFilters units={orgMaps.unitList} depthOf={orgMaps.depthOf} locations={orgMaps.locationList} /> <EmployeeFilters units={orgMaps.unitList} depthOf={orgMaps.depthOf} locations={orgMaps.locationList} />
</Suspense> </Suspense>
<p className="text-sm text-ink-muted">{count ?? 0} Mitarbeiter:innen gefunden</p> <p className="text-sm text-ink-muted">
{count ?? 0} Mitarbeiter:innen gefunden
{/* Wenn kein Name passte, wurde nach der Position gesucht. Ohne diesen
Hinweis wirkt das Ergebnis, als hätte die Suche etwas erfunden. */}
{ueberPosition && (count ?? 0) > 0 && (
<span className="text-ink-muted"> · kein Namenstreffer, gesucht nach Position</span>
)}
</p>
<div className={`overflow-x-auto ${CARD_CLASS}`}> <div className={`overflow-x-auto ${CARD_CLASS}`}>
<table className="w-full min-w-[800px] text-sm"> <table className="w-full min-w-[800px] text-sm">