Files
alpenwerk-hr/lib/positions.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

194 lines
7.2 KiB
TypeScript

import type { Tx } from "./db";
import { jsonArrayFrom } from "./db/json";
import { fmtName, todayIso } from "./format";
import { breadcrumbLabel, buildOrgMaps, orgMapsAbfragen, type OrgEb, type OrgMaps } from "./org";
// Eine offene Stelle ist keine eigene Sache mehr. Sie ist eine Planstelle
// ohne laufende Besetzung — Vakanz ist eine Eigenschaft der Planstelle, kein
// zweites Objekt daneben, das mit der Organisation synchron gehalten werden
// müsste.
export type OpenPositionResolved = {
id: string;
position_number: string;
title: string;
org_unit_id: string;
is_chief: boolean;
valid_from: string;
valid_to: string | null;
/** Die Einheit selbst — für den Änderungsdialog, der sie vorbelegt. */
org_unit_name: string;
/**
* Ob die Planstelle heute schon gilt.
*
* Eine, die erst zum 01.10. entsteht, ist nicht „seit 2 Tagen unbesetzt" —
* sie ist geplant. Beides in einer Liste zu zeigen ist richtig, beides
* gleich zu benennen wäre falsch.
*/
future: boolean;
/** Wer die Stelle nach der Berichtslinie führen wird. */
managerName: string | null;
orgLabel: string;
/** Seit wann die Stelle unbesetzt ist: Ende der letzten Besetzung, sonst ihr Beginn. */
vacantSince: string;
};
/**
* Wer eine unbesetzte Planstelle führen würde: die Leitung der eigenen
* Einheit, für eine Leitungsplanstelle die der übergeordneten — dieselbe
* Regel wie in om_reporting_lines(), nur ohne Inhaber:in, für die sie gälte.
*/
function managerUnitFor(maps: OrgMaps, orgUnitId: string, isChief: boolean): string | null {
if (!isChief) return orgUnitId;
return maps.units.get(orgUnitId)?.parent_id ?? null;
}
export type OffeneStelle = {
id: string;
position_number: string;
org_unit_id: string;
is_chief: boolean;
valid_from: string;
valid_to: string | null;
title: string;
};
/**
* Die unbesetzten Planstellen als *Teilabfrage*.
*
* Als eigene Funktion, damit eine Seite sie zusammen mit ihren übrigen
* Lesevorgängen in einer Rundreise holen kann statt in einer eigenen — siehe
* lib/db/json.ts. Wer nichts weiter zu holen hat, nimmt loadOpenPositions().
*
* Unbesetzt heisst: keine Zuordnung, die noch gilt — **auch keine, die erst
* beginnt.**
*
* Der Unterschied ist kein Feinschliff. Wer unterschrieben hat und am 24.09.
* anfängt, belegt die Planstelle heute schon; sie steht nur noch nicht besetzt
* da. Die frühere Fassung fragte „sitzt heute jemand darauf?" und listete
* solche Stellen als offen — mit „seit 2 Tagen unbesetzt" daneben. Aus dieser
* Liste speist sich auch die Auswahl im Einstellungsassistenten, also lud sie
* dazu ein, dieselbe Stelle ein zweites Mal zu besetzen. Aufgefallen wäre das
* erst am Teilindex der Datenbank, nach dem Gespräch mit der zweiten Person.
*
* Eine beendete Zuordnung (valid_to in der Vergangenheit) gibt die Stelle
* dagegen wieder frei — deshalb bleibt die Bedingung auf valid_to.
*
* Künftige Planstellen bleiben drin. Sie sind der Grund, warum diese Ansicht
* existiert: eine Stelle, die zum Quartalswechsel entsteht, muss vorher
* sichtbar und planbar sein.
*/
export function offeneStellenAbfrage(eb: OrgEb, asOf: string) {
return eb
.selectFrom("om_positions as p")
.innerJoin("jobs as j", "j.id", "p.job_id")
.select(["p.id", "p.position_number", "p.org_unit_id", "p.is_chief", "p.valid_from", "p.valid_to", "j.title"])
.where((e) => e.or([e("p.valid_to", "is", null), e("p.valid_to", ">", asOf)]))
// Als NOT EXISTS in der Datenbank statt als Filter über alle Planstellen
// im Speicher.
.where((e) =>
e.not(
e.exists(
e
.selectFrom("position_assignments as a")
.select("a.id")
.whereRef("a.position_id", "=", "p.id")
.where((e2) => e2.or([e2("a.valid_to", "is", null), e2("a.valid_to", ">", asOf)]))
)
)
)
.orderBy("p.position_number");
}
/**
* Der zweite Teil: die beiden Nachschläge, die erst gestellt werden können,
* wenn feststeht, welche Stellen offen sind — seit wann sie leer stehen, und
* wer sie führen würde. Beide in einer Rundreise.
*/
export async function resolveOpenPositions(
tx: Tx,
orgMaps: OrgMaps,
open: OffeneStelle[],
asOf: string = todayIso()
): Promise<OpenPositionResolved[]> {
if (open.length === 0) return [];
const positionIds = open.map((p) => p.id);
const chiefUnitIds = Array.from(
new Set(
open.map((p) => managerUnitFor(orgMaps, p.org_unit_id, p.is_chief)).filter((id): id is string => Boolean(id))
)
);
const { ended, chiefs } = await tx
.selectNoFrom((eb) => [
jsonArrayFrom(
eb
.selectFrom("position_assignments")
.select(["position_id", "valid_to"])
.where("position_id", "in", positionIds)
.where("valid_to", "is not", null)
).as("ended"),
jsonArrayFrom(
eb
.selectFrom("om_positions as p")
.innerJoin("position_assignments as a", "a.position_id", "p.id")
.innerJoin("employees as e", "e.id", "a.employee_id")
.select(["p.org_unit_id", "e.first_name", "e.last_name"])
.where("p.is_chief", "=", true)
.where("p.valid_to", "is", null)
.where("a.valid_to", "is", null)
// Ohne Einheiten darf hier keine Liste stehen: `in ()` ist ein
// Syntaxfehler, und `in ('')` bricht an der uuid-Umwandlung ab
// statt nichts zu liefern. Dann also eine Bedingung, die nie zutrifft.
.where((e) => (chiefUnitIds.length > 0 ? e("p.org_unit_id", "in", chiefUnitIds) : e.lit(false)))
).as("chiefs"),
])
.executeTakeFirstOrThrow();
const lastEndByPosition = new Map<string, string>();
for (const e of ended) {
const prev = lastEndByPosition.get(e.position_id);
if (e.valid_to && (!prev || e.valid_to > prev)) lastEndByPosition.set(e.position_id, e.valid_to);
}
const chiefNameByUnit = new Map(chiefs.map((c) => [c.org_unit_id, fmtName(c.first_name, c.last_name)]));
return open.map((p) => {
const managerUnit = managerUnitFor(orgMaps, p.org_unit_id, p.is_chief);
return {
id: p.id,
position_number: p.position_number,
title: p.title,
org_unit_id: p.org_unit_id,
is_chief: p.is_chief,
valid_from: p.valid_from,
valid_to: p.valid_to,
org_unit_name: orgMaps.units.get(p.org_unit_id)?.name ?? "",
future: p.valid_from > asOf,
managerName: managerUnit ? (chiefNameByUnit.get(managerUnit) ?? null) : null,
orgLabel: breadcrumbLabel(orgMaps, p.org_unit_id),
vacantSince: lastEndByPosition.get(p.id) ?? p.valid_from,
};
});
}
/**
* Der bequeme Weg: Organisationsbaum und offene Stellen in einer Rundreise,
* die beiden Nachschläge in einer zweiten.
*
* Zwei sind das Minimum — was in der zweiten steht, hängt vom Ergebnis der
* ersten ab.
*/
export async function loadOpenPositions(tx: Tx): Promise<OpenPositionResolved[]> {
const asOf = todayIso();
const { units, locations, open } = await tx
.selectNoFrom((eb) => [
...orgMapsAbfragen(eb),
jsonArrayFrom(offeneStellenAbfrage(eb, asOf)).as("open"),
])
.executeTakeFirstOrThrow();
return resolveOpenPositions(tx, buildOrgMaps(units as never, locations as never), open as OffeneStelle[], asOf);
}