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>
221 lines
7.7 KiB
TypeScript
221 lines
7.7 KiB
TypeScript
import { sql, type Tx } from "./db";
|
|
import { addDaysIso } from "./format";
|
|
import type { OrgEb } from "./org";
|
|
|
|
/**
|
|
* Der Stichtag, zu dem die Organisation *dieser* Person betrachtet wird.
|
|
*
|
|
* „Heute" ist für zwei Gruppen die falsche Frage. Wer am 01.09. anfängt, hat
|
|
* heute keine laufende Besetzung — om_reporting_lines() liefert dann gar
|
|
* keine Zeile, und die Akte behauptet „Keine Führungskraft", obwohl das Team
|
|
* eine hat. Wer ausgetreten ist, ebenso.
|
|
*
|
|
* Die Stammdaten zeigten die Planstelle trotzdem an, weil pickPlacements()
|
|
* ersatzweise auf die nächstbeste Zuordnung zurückfällt. Zwei Vorstellungen
|
|
* davon, wo jemand sitzt — eine nachsichtige und eine strenge — und in der
|
|
* Akte standen sie nebeneinander.
|
|
*
|
|
* Deshalb: der Stichtag wird in das Beschäftigungsverhältnis hineingezogen.
|
|
* Für künftige Eintritte auf den ersten Tag, für Ausgetretene auf den
|
|
* letzten. Sonst bleibt es heute.
|
|
*/
|
|
export function orgAsOf(
|
|
employee: { entry_date: string; exit_date: string | null },
|
|
today: string
|
|
): string {
|
|
if (employee.entry_date > today) return employee.entry_date;
|
|
// exit_date ist ausschliessend wie überall im Modell: der letzte Arbeitstag
|
|
// ist der Tag davor.
|
|
if (employee.exit_date && employee.exit_date <= today) return addDaysIso(employee.exit_date, -1);
|
|
return today;
|
|
}
|
|
|
|
// Wo jemand in der Organisation steht, steht nicht mehr auf der Person. Es
|
|
// ergibt sich aus der Planstelle, die sie zum Stichtag innehat:
|
|
//
|
|
// employees ──A008──> position_assignments ──> om_positions ──> org_units
|
|
// └────────> jobs
|
|
//
|
|
// Das ist der Grund, warum es diese Datei gibt: die Verkettung braucht es an
|
|
// einem Dutzend Stellen, und sie zeitrichtig aufzulösen ist die Arbeit.
|
|
|
|
export type Placement = {
|
|
employeeId: string;
|
|
positionId: string;
|
|
positionNumber: string;
|
|
orgUnitId: string;
|
|
isChief: boolean;
|
|
jobTitle: string;
|
|
validFrom: string;
|
|
validTo: string | null;
|
|
/** Die Besetzung läuft am Stichtag; sonst ist es die zuletzt beendete. */
|
|
current: boolean;
|
|
};
|
|
|
|
type Row = {
|
|
employee_id: string;
|
|
valid_from: string;
|
|
valid_to: string | null;
|
|
position_id: string;
|
|
position_number: string;
|
|
org_unit_id: string;
|
|
is_chief: boolean;
|
|
job_title: string;
|
|
};
|
|
|
|
function toPlacement(row: Row, asOf: string): Placement {
|
|
return {
|
|
employeeId: row.employee_id,
|
|
positionId: row.position_id,
|
|
positionNumber: row.position_number,
|
|
orgUnitId: row.org_unit_id,
|
|
isChief: row.is_chief,
|
|
jobTitle: row.job_title,
|
|
validFrom: row.valid_from,
|
|
validTo: row.valid_to,
|
|
current: row.valid_from <= asOf && (row.valid_to === null || row.valid_to > asOf),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Die am Stichtag laufende Besetzung je Person — und für alle, die zu dem
|
|
* Zeitpunkt keine hatten, die zuletzt beendete. Ohne diesen Rückfall stünde
|
|
* bei jeder ausgetretenen Person „–" statt der Stelle, die sie innehatte.
|
|
*/
|
|
export function pickPlacements(rows: Row[], asOf: string): Map<string, Placement> {
|
|
const byEmployee = new Map<string, Placement>();
|
|
for (const row of rows) {
|
|
const p = toPlacement(row, asOf);
|
|
const best = byEmployee.get(p.employeeId);
|
|
if (!best) {
|
|
byEmployee.set(p.employeeId, p);
|
|
continue;
|
|
}
|
|
// Laufend schlägt beendet; unter beendeten gewinnt die jüngste.
|
|
if (p.current && !best.current) byEmployee.set(p.employeeId, p);
|
|
else if (p.current === best.current && p.validFrom > best.validFrom) byEmployee.set(p.employeeId, p);
|
|
}
|
|
return byEmployee;
|
|
}
|
|
|
|
/**
|
|
* Die Besetzungen als *Teilabfrage* — zum Einhängen in die eine Abfrage, die
|
|
* eine Seite ohnehin stellt (lib/db/json.ts).
|
|
*
|
|
* Ein Join statt einer eingebetteten Ressource. Und ohne die
|
|
* 1000-Zeilen-Grenze von PostgREST fällt das seitenweise Nachladen weg, das
|
|
* es dafür brauchte.
|
|
*/
|
|
export function besetzungenAbfrage(eb: OrgEb, employeeIds?: string[]) {
|
|
const q = eb
|
|
.selectFrom("position_assignments as pa")
|
|
.innerJoin("om_positions as p", "p.id", "pa.position_id")
|
|
.innerJoin("jobs as j", "j.id", "p.job_id")
|
|
.select([
|
|
"pa.employee_id",
|
|
"pa.valid_from",
|
|
"pa.valid_to",
|
|
"p.id as position_id",
|
|
"p.position_number",
|
|
"p.org_unit_id",
|
|
"p.is_chief",
|
|
"j.title as job_title",
|
|
])
|
|
.orderBy("pa.employee_id");
|
|
|
|
return employeeIds ? q.where("pa.employee_id", "in", employeeIds) : q;
|
|
}
|
|
|
|
export async function loadPlacements(
|
|
tx: Tx,
|
|
{ asOf, employeeIds }: { asOf: string; employeeIds?: string[] }
|
|
): Promise<Map<string, Placement>> {
|
|
if (employeeIds?.length === 0) return new Map();
|
|
|
|
// Ein Join statt einer eingebetteten Ressource. Und ohne die
|
|
// 1000-Zeilen-Grenze von PostgREST fällt das seitenweise Nachladen weg,
|
|
// das es dafür brauchte.
|
|
let q = tx
|
|
.selectFrom("position_assignments as pa")
|
|
.innerJoin("om_positions as p", "p.id", "pa.position_id")
|
|
.innerJoin("jobs as j", "j.id", "p.job_id")
|
|
.select([
|
|
"pa.employee_id",
|
|
"pa.valid_from",
|
|
"pa.valid_to",
|
|
"p.id as position_id",
|
|
"p.position_number",
|
|
"p.org_unit_id",
|
|
"p.is_chief",
|
|
"j.title as job_title",
|
|
])
|
|
.orderBy("pa.employee_id");
|
|
|
|
if (employeeIds) q = q.where("pa.employee_id", "in", employeeIds);
|
|
|
|
return pickPlacements((await q.execute()) as Row[], asOf);
|
|
}
|
|
|
|
// ── Abgeleitete Berichtslinie ──────────────────────────────────────
|
|
// Sie steht nirgends als Spalte; om_reporting_lines() rechnet sie aus dem
|
|
// Baum aus. formal_manager_id ist die zuständige Leitung, acting_manager_id
|
|
// die nächste besetzte und anwesende darüber — beides, damit sich in der
|
|
// Oberfläche zeigen lässt, dass eine Vertretung im Spiel ist, statt sie
|
|
// stillschweigend als die echte Führungskraft auszugeben.
|
|
|
|
export type ReportingLine = {
|
|
employee_id: string;
|
|
position_id: string;
|
|
org_unit_id: string;
|
|
is_chief: boolean;
|
|
formal_manager_id: string | null;
|
|
acting_manager_id: string | null;
|
|
};
|
|
|
|
/**
|
|
* `filter` schränkt die Funktion selbst ein, nicht das Ergebnis im Speicher —
|
|
* bei der Detailseite wandern damit neun Zeilen über die Leitung statt
|
|
* achthundert.
|
|
*/
|
|
/**
|
|
* Die Berichtslinien als *Teilabfrage* — zum Einhängen in die eine Abfrage,
|
|
* die eine Seite ohnehin stellt (lib/db/json.ts).
|
|
*
|
|
* `filter` schränkt die Funktion selbst ein, nicht das Ergebnis im Speicher.
|
|
*/
|
|
export function berichtslinienAbfrage(
|
|
eb: OrgEb,
|
|
asOf: string,
|
|
filter?: { employeeId?: string; actingManagerId?: string }
|
|
) {
|
|
let q = eb
|
|
.selectFrom(sql<ReportingLine>`om_reporting_lines(${asOf}::date)`.as("l"))
|
|
.select(["l.employee_id", "l.position_id", "l.org_unit_id", "l.is_chief", "l.formal_manager_id", "l.acting_manager_id"]);
|
|
if (filter?.employeeId) q = q.where("l.employee_id", "=", filter.employeeId);
|
|
if (filter?.actingManagerId) q = q.where("l.acting_manager_id", "=", filter.actingManagerId);
|
|
return q;
|
|
}
|
|
|
|
export async function loadReportingLines(
|
|
tx: Tx,
|
|
asOf: string,
|
|
filter?: { employeeId?: string; actingManagerId?: string }
|
|
): Promise<ReportingLine[]> {
|
|
const conditions = [sql`true`];
|
|
if (filter?.employeeId) conditions.push(sql`employee_id = ${filter.employeeId}::uuid`);
|
|
if (filter?.actingManagerId) conditions.push(sql`acting_manager_id = ${filter.actingManagerId}::uuid`);
|
|
|
|
const result = await sql<ReportingLine>`
|
|
select * from om_reporting_lines(${asOf}::date)
|
|
where ${sql.join(conditions, sql` and `)}
|
|
`.execute(tx);
|
|
|
|
return result.rows;
|
|
}
|
|
|
|
/** Wie loadReportingLines, aber als Karte über die Personen-Kennung. */
|
|
export async function loadReportingLineMap(tx: Tx, asOf: string): Promise<Map<string, ReportingLine>> {
|
|
const lines = await loadReportingLines(tx, asOf);
|
|
return new Map(lines.map((l) => [l.employee_id, l]));
|
|
}
|