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())
.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.
// „Produktion" meint die Abteilungen und Teams darunter — in der Einheit
// 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
// API-Schicht einen bedingt zusammengesetzten Select-String nicht
// auflösen konnte.
const base = () => {
const base = (nurNamen: boolean) => {
let q = tx.selectFrom("employees");
if (unitFilter) {
@@ -91,44 +91,35 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
if (istPersonalnummer(term)) {
q = q.where("personnel_number", "=", Number(term));
} else {
// Wortweise statt am Stück, und **jedes** Wort muss irgendwo
// treffen.
// ── Wie hier gesucht wird ──────────────────────────────────
//
// Vorher wurde der ganze Begriff gegen jedes Feld einzeln geprüft.
// „Michael Winkler" stand damit in keinem Feld — weder Vor- noch
// Nachname enthalten die Zeichenkette — und die Suche kam auf null
// Treffer, obwohl es vierzehn Winkler gibt. Das las sich wie „die
// Person gibt es nicht".
// **Wortweise, Reihenfolge egal.** Jedes Wort muss treffen, aber
// nicht in einer bestimmten Ordnung: „Winkler micha" und „micha
// Winkler" führen beide zu Michaela Winkler. Am Stück gesucht stand
// „Michael Winkler" in keinem einzelnen Feld und ergab null Treffer,
// obwohl es vierzehn Winkler gibt.
//
// Die Zerlegung macht die Reihenfolge nebensächlich: „Winkler
// micha" und „micha Winkler" führen beide zu Michaela Winkler. Das
// ist mehr, als die Rückmeldung verlangte — sie bat um die
// umgekehrte Reihenfolge —, aber eine Suche, bei der man sich die
// richtige Reihenfolge merken muss, hat dasselbe Problem nur
// gespiegelt.
// **Am Wortanfang, nicht mittendrin.** Als Teilzeichenkette traf ein
// „H" auf T-h-omas und Kat-h-arina — bei „Winkler H" kamen alle
// sieben Winkler zurück. Trennzeichen zählen als Wortgrenze, damit
// „dreher" auch „CNC-Dreher:in" findet.
//
// Verglichen wird gegen den zusammengesetzten Namen, weil genau
// darauf der Trigramm-Index liegt (idx_employees_name_trgm).
// Getrennte Felder hätten ihn ungenutzt gelassen.
// Jedes Wort trifft am **Wortanfang**, nicht irgendwo mittendrin.
// **Namen vor Positionen.** Die Position mitzudurchsuchen ist
// nützlich („dreher"), darf aber eine Namenssuche nicht verwässern:
// bei „Winkler M" tauchten sonst Karin Winkler (Montagemitarbeiterin)
// 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
// H" traf das „H" auf T-h-omas, Kat-h-arina und CNC-Dre-h-er:in —
// die Suche gab alle sieben Winkler zurück, obwohl genau eine Hannah
// heisst. Je kürzer die Eingabe, desto unbrauchbarer wurde sie, und
// ein Anfangsbuchstabe ist die kürzeste sinnvolle Eingabe überhaupt.
//
// Gesucht wird über Vorname, Nachname und Position zusammen, wobei
// 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), '-/:.,', ' ')`;
// Der Trigramm-Index auf dem zusammengesetzten Namen greift bei
// diesem Ausdruck nicht mehr. Bei knapp neunhundert Zeilen liest
// Postgres die Tabelle in wenigen Millisekunden; ein Index auf
// demselben Ausdruck holt das zurück, sobald das nicht mehr stimmt.
const heuhaufen = nurNamen
? sql<string>`translate(lower(first_name || ' ' || last_name), '-/:.,', ' ')`
: sql<string>`translate(lower(first_name || ' ' || last_name || ' ' || job_title), '-/:.,', ' ')`;
q = q.where((eb) =>
eb.and(
// Als Parameter gebunden, nicht in die Abfrage geschrieben.
@@ -150,8 +141,17 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
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([
base()
base(nurNamen)
.select([
"id",
"first_name",
@@ -173,7 +173,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
.limit(PAGE_SIZE)
.offset((page - 1) * PAGE_SIZE)
.execute(),
base()
base(nurNamen)
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
.executeTakeFirst(),
]);
@@ -182,7 +182,15 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
// Seite, nicht für den ganzen Bestand.
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));
@@ -192,7 +200,14 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
<Suspense>
<EmployeeFilters units={orgMaps.unitList} depthOf={orgMaps.depthOf} locations={orgMaps.locationList} />
</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}`}>
<table className="w-full min-w-[800px] text-sm">