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>
194 lines
7.2 KiB
TypeScript
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);
|
|
}
|