Files
alpenwerk-hr/lib/employee-detail-data.ts
Maximilian Stubhan 91b2b3406b Stop waiting on the network eleven times per page
The app got slower as pages grew, and the reason was not the queries. It
was their number.

A transaction is pinned to one connection, and a connection runs queries
one after another. Every Promise.all in a withUser block looked like
concurrency and was a queue. Measured against the real database: the
round trip is ~36 ms, ten trivial `select 1` over one connection take
343 ms, over ten connections 39 ms. Nothing here is slow — the whole
dashboard payload is under 200 kB, and every table is around a thousand
rows.

More connections is the wrong answer: the RLS session context is per
transaction, so parallel reads mean parallel transactions, and those
multiply the connections the database will grant. Fewer round trips
instead. Postgres will return each sub-select as its own JSON column of
one result.

Per page view, counting the transaction frame:

  shell (paid by every page)  10 → 4
  overview                    14 → 5
  employee file               14 → 7
  employee list                8 → 6

The overview plus its shell went from 24 round trips to 9 — about 860 ms
of pure waiting down to about 320 ms.

The one trap is documented where it bites: inside json_agg, Postgres
formats values itself and the driver's parsers (lib/db/pool.ts) never
see them. Dates, numerics and uuids come out identical; timestamptz does
not — "+00:00" where the driver gives "…Z". Timestamps are compared as
strings in lib/history.ts to decide what happened later, and those two
forms sort against each other wrongly. Every timestamptz in a bundled
query therefore goes through zeitstempel(), which was checked
character-for-character against the driver.

Four loaders moved out of their pages into lib/ so the number of round
trips can be measured without building a React tree, and so the new path
could be held against the old one field by field: same rows, same order,
same strings, for the overview and for four employee files chosen to
differ (with history, a chief, a planned entry, one with dependents).

withUser now counts the queries in each transaction and says so in
development past a threshold. Without that, this grows back: each new
tile brings its own query, and nobody notices until everybody does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 19:31:36 +02:00

126 lines
4.9 KiB
TypeScript

import type { Tx } from "./db";
import { jsonArrayFrom, zeitstempel } from "./db/json";
import { todayIso } from "./format";
import { buildOrgMaps, orgMapsAbfragen } from "./org";
import { berichtslinienAbfrage, besetzungenAbfrage, orgAsOf, pickPlacements } from "./placement";
import { offeneStellenAbfrage, resolveOpenPositions, type OffeneStelle } from "./positions";
// Was die Mitarbeiterakte liest — in vier Rundreisen statt in zwölf.
//
// Vorher: die Person, dann acht Dinge in einem Promise.all, dann die Namen der
// Beteiligten. Das Promise.all war keine Gleichzeitigkeit — eine Transaktion
// hängt an einer Verbindung (lib/db/json.ts). Bei rund 36 ms Umlaufzeit war
// diese Seite damit die teuerste der Anwendung.
//
// Die drei Tabellen mit `selectAll()` stehen hier ausgeschrieben. Das ist
// keine Umständlichkeit: innerhalb von JSON formatiert Postgres timestamptz
// anders als der Treiber sonst, und `created_at` entscheidet in lib/history.ts
// darüber, was später passiert ist. Ausgeschrieben ist sichtbar, welche Spalte
// durch zeitstempel() geht — bei selectAll() wäre es unsichtbar falsch.
export async function loadEmployeeDetail(tx: Tx, id: string, today: string = todayIso()) {
// Die Person zuerst, allein: erst aus Eintritt und Austritt ergibt sich der
// Stichtag, zu dem ihre Organisation überhaupt eine Antwort hat. Eine
// Person, die am 01.09. anfängt, hat heute keine laufende Besetzung — zu
// heute gefragt lieferte om_reporting_lines() nichts, und die Akte
// behauptete „Keine Führungskraft", obwohl das Team eine hat.
const employee = await tx.selectFrom("employees").selectAll().where("id", "=", id).executeTakeFirst();
if (!employee) return null;
const asOf = orgAsOf(employee, today);
const g = await tx
.selectNoFrom((eb) => [
// Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie
// kommen aus om_reporting_lines(). Beide schränken *in* der Funktion
// ein, es wandern also neun Zeilen über die Leitung und nicht achthundert.
jsonArrayFrom(berichtslinienAbfrage(eb, asOf, { employeeId: id })).as("ownLines"),
jsonArrayFrom(berichtslinienAbfrage(eb, asOf, { actingManagerId: id })).as("reports"),
jsonArrayFrom(
eb
.selectFrom("employee_history")
.select(["id", "employee_id", "event_date", "event_type", "description", "changes", "pending_id"])
.select((x) => zeitstempel(x.ref("created_at")).as("created_at"))
.where("employee_id", "=", id)
.orderBy("event_date", "desc")
.orderBy("created_at", "desc")
).as("history"),
jsonArrayFrom(
eb
.selectFrom("employee_dependents")
.select(["id", "employee_id", "first_name", "last_name", "relationship", "sv_nummer", "birth_date"])
.select((x) => zeitstempel(x.ref("created_at")).as("created_at"))
.where("employee_id", "=", id)
.orderBy("created_at")
).as("dependents"),
jsonArrayFrom(
eb
.selectFrom("employee_notes")
.select([
"id",
"employee_id",
"author_user_id",
"author_name",
"category",
"note_text",
"due_date",
"done",
"done_by",
])
.select((x) => [
zeitstempel(x.ref("created_at")).as("created_at"),
zeitstempel(x.ref("done_at")).as("done_at"),
])
.where("employee_id", "=", id)
.orderBy("created_at", "desc")
).as("notes"),
...orgMapsAbfragen(eb),
jsonArrayFrom(besetzungenAbfrage(eb, [id])).as("placementRows"),
// Die offenen Planstellen bleiben bei heute: sie gehören zur
// Organisation, nicht zu dieser Person.
jsonArrayFrom(offeneStellenAbfrage(eb, today)).as("open"),
])
.executeTakeFirstOrThrow();
const orgMaps = buildOrgMaps(g.units as never, g.locations as never);
const line = g.ownLines[0] ?? null;
// Namen für die beteiligten Personen in einem Zug: die Vertretung, die
// formal zuständige Leitung und die direkten Berichte.
const relatedIds = Array.from(
new Set(
[line?.acting_manager_id, line?.formal_manager_id, ...g.reports.map((r) => r.employee_id)].filter(
(x): x is string => Boolean(x)
)
)
);
const [relatedRows, openPositions] = await Promise.all([
relatedIds.length
? tx
.selectFrom("employees")
.select(["id", "first_name", "last_name", "job_title", "status"])
.where("id", "in", relatedIds)
.execute()
: Promise.resolve([]),
resolveOpenPositions(tx, orgMaps, g.open as OffeneStelle[], today),
]);
return {
employee,
line,
reports: g.reports,
history: g.history,
dependents: g.dependents,
notes: g.notes,
orgMaps,
placement: pickPlacements(g.placementRows as never, asOf).get(id) ?? null,
openPositions,
byId: new Map(relatedRows.map((e) => [e.id, e])),
};
}