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>
This commit is contained in:
@@ -3,9 +3,8 @@ import { EmployeeDetail } from "@/components/employees/EmployeeDetail";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
import { todayIso } from "@/lib/format";
|
||||
import { breadcrumbLabel, loadOrgMaps } from "@/lib/org";
|
||||
import { loadPlacements, loadReportingLines, orgAsOf } from "@/lib/placement";
|
||||
import { loadOpenPositions } from "@/lib/positions";
|
||||
import { breadcrumbLabel } from "@/lib/org";
|
||||
import { loadEmployeeDetail } from "@/lib/employee-detail-data";
|
||||
|
||||
type PageProps = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -13,72 +12,9 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const today = todayIso();
|
||||
|
||||
const data = await withUser(await currentUserId(), async (tx) => {
|
||||
// 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);
|
||||
|
||||
// Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie
|
||||
// kommen aus om_reporting_lines(). Beide Abfragen schränken *in* der
|
||||
// Funktion ein, es wandern also neun Zeilen über die Leitung und nicht
|
||||
// achthundert.
|
||||
const [ownLines, reports, history, dependents, notes, orgMaps, placements, openPositions] = await Promise.all([
|
||||
loadReportingLines(tx, asOf, { employeeId: id }),
|
||||
loadReportingLines(tx, asOf, { actingManagerId: id }),
|
||||
tx
|
||||
.selectFrom("employee_history")
|
||||
.selectAll()
|
||||
.where("employee_id", "=", id)
|
||||
.orderBy("event_date", "desc")
|
||||
.orderBy("created_at", "desc")
|
||||
.execute(),
|
||||
tx.selectFrom("employee_dependents").selectAll().where("employee_id", "=", id).orderBy("created_at").execute(),
|
||||
tx.selectFrom("employee_notes").selectAll().where("employee_id", "=", id).orderBy("created_at", "desc").execute(),
|
||||
loadOrgMaps(tx),
|
||||
loadPlacements(tx, { asOf, employeeIds: [id] }),
|
||||
// Die offenen Planstellen bleiben bei heute: sie gehören zur
|
||||
// Organisation, nicht zu dieser Person.
|
||||
loadOpenPositions(tx),
|
||||
]);
|
||||
|
||||
const line = 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, ...reports.map((r) => r.employee_id)].filter(
|
||||
(x): x is string => Boolean(x)
|
||||
)
|
||||
)
|
||||
);
|
||||
const relatedRows = relatedIds.length
|
||||
? await tx
|
||||
.selectFrom("employees")
|
||||
.select(["id", "first_name", "last_name", "job_title", "status"])
|
||||
.where("id", "in", relatedIds)
|
||||
.execute()
|
||||
: [];
|
||||
|
||||
return {
|
||||
employee,
|
||||
line,
|
||||
reports,
|
||||
history,
|
||||
dependents,
|
||||
notes,
|
||||
orgMaps,
|
||||
placement: placements.get(id) ?? null,
|
||||
openPositions,
|
||||
byId: new Map(relatedRows.map((e) => [e.id, e])),
|
||||
};
|
||||
});
|
||||
// Was die Akte liest und in wie vielen Rundreisen, steht in
|
||||
// lib/employee-detail-data.ts.
|
||||
const data = await withUser(await currentUserId(), (tx) => loadEmployeeDetail(tx, id, today));
|
||||
|
||||
if (!data) notFound();
|
||||
const { employee, line, reports, history, dependents, notes, orgMaps, placement, openPositions, byId } = data;
|
||||
|
||||
@@ -7,10 +7,11 @@ import { Pagination } from "@/components/ui/Pagination";
|
||||
import { StatusChip } from "@/components/ui/StatusChip";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { sql, withUser } from "@/lib/db";
|
||||
import { jsonArrayFrom, jsonObjectFrom } from "@/lib/db/json";
|
||||
import { istPersonalnummer, suchMuster } from "@/lib/employee-search";
|
||||
import { derivedStatusFilter } from "@/lib/employee-status-filter";
|
||||
import { fmtDate, fmtName, todayIso } from "@/lib/format";
|
||||
import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf } from "@/lib/org";
|
||||
import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf, type OrgEb } from "@/lib/org";
|
||||
import { loadPlacements } from "@/lib/placement";
|
||||
import type { EmploymentStatus } from "@/lib/supabase/types";
|
||||
|
||||
@@ -62,8 +63,11 @@ 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 = (nurNamen: boolean) => {
|
||||
let q = tx.selectFrom("employees");
|
||||
// Der Ausdrucksbauer wird durchgereicht, damit dieselbe Filterkette
|
||||
// einmal für die Seite und einmal für die Zählung in *einer* Abfrage
|
||||
// stehen kann.
|
||||
const base = (nurNamen: boolean, eb: OrgEb = tx as never) => {
|
||||
let q = eb.selectFrom("employees");
|
||||
|
||||
if (unitFilter) {
|
||||
// Nach Organisationseinheit gefiltert wird über die *laufende*
|
||||
@@ -150,33 +154,37 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
||||
: 0;
|
||||
const nurNamen = sucheNachNamen && namensTreffer > 0;
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
base(nurNamen)
|
||||
.select([
|
||||
"id",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"personnel_number",
|
||||
"job_title",
|
||||
"location_id",
|
||||
"entry_date",
|
||||
"employment_type",
|
||||
"weekly_hours",
|
||||
"status",
|
||||
"absence_type",
|
||||
])
|
||||
// Nach id als zweitem Kriterium: bei gleichem Nachnamen wäre die
|
||||
// Reihenfolge sonst unbestimmt, und dieselbe Person könnte auf zwei
|
||||
// Seiten erscheinen oder auf keiner.
|
||||
.orderBy("last_name")
|
||||
.orderBy("id")
|
||||
.limit(PAGE_SIZE)
|
||||
.offset((page - 1) * PAGE_SIZE)
|
||||
.execute(),
|
||||
base(nurNamen)
|
||||
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
|
||||
.executeTakeFirst(),
|
||||
]);
|
||||
// Seite und Gesamtzahl in *einer* Rundreise. Als Promise.all sah das nach
|
||||
// Gleichzeitigkeit aus und war keine: eine Transaktion hängt an einer
|
||||
// Verbindung, und darüber laufen Abfragen nacheinander (lib/db/json.ts).
|
||||
const { rows, total } = await tx
|
||||
.selectNoFrom((eb) => [
|
||||
jsonArrayFrom(
|
||||
base(nurNamen, eb)
|
||||
.select([
|
||||
"id",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"personnel_number",
|
||||
"job_title",
|
||||
"location_id",
|
||||
"entry_date",
|
||||
"employment_type",
|
||||
"weekly_hours",
|
||||
"status",
|
||||
"absence_type",
|
||||
])
|
||||
// Nach id als zweitem Kriterium: bei gleichem Nachnamen wäre die
|
||||
// Reihenfolge sonst unbestimmt, und dieselbe Person könnte auf zwei
|
||||
// Seiten erscheinen oder auf keiner.
|
||||
.orderBy("last_name")
|
||||
.orderBy("id")
|
||||
.limit(PAGE_SIZE)
|
||||
.offset((page - 1) * PAGE_SIZE)
|
||||
).as("rows"),
|
||||
jsonObjectFrom(base(nurNamen, eb).select(({ fn }) => fn.countAll<string>().as("anzahl"))).as("total"),
|
||||
])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
// Die Einordnung kommt über die Planstelle — nur für die 15 Zeilen dieser
|
||||
// Seite, nicht für den ganzen Bestand.
|
||||
|
||||
@@ -4,46 +4,23 @@ import { HireWizardProvider } from "@/components/hire/HireWizardContext";
|
||||
import { AppShell } from "@/components/shell/AppShell";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
import { loadOpenNotes } from "@/lib/notes";
|
||||
import { loadOpenPositions } from "@/lib/positions";
|
||||
import { loadShellData } from "@/lib/shell-data";
|
||||
|
||||
export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
const userId = await currentUserId();
|
||||
if (!userId) redirect("/login");
|
||||
|
||||
// Alles in *einer* Transaktion, weil nur dort der Sitzungskontext gilt —
|
||||
// und damit nebenbei auf einem einheitlichen Lesestand.
|
||||
const data = await withUser(userId, async (tx) => {
|
||||
// Hier — und nicht im Proxy — fällt die Entscheidung über den Zugang.
|
||||
// Der Proxy prüft nur, ob überhaupt jemand angemeldet ist; er hat keine
|
||||
// Datenbankverbindung. Diese Abfrage läuft bei jedem Aufbau frisch, eine
|
||||
// entzogene Freischaltung wirkt also sofort statt erst mit dem nächsten
|
||||
// Sitzungstoken. Die eigentliche Grenze bleibt darunter RLS.
|
||||
const profile = await tx
|
||||
.selectFrom("profiles")
|
||||
.select(["full_name", "email", "role", "is_active"])
|
||||
.where("id", "=", userId)
|
||||
.executeTakeFirst();
|
||||
if (profile?.role !== "hr" || profile?.is_active !== true) return null;
|
||||
|
||||
const [openPositions, locations, drafts, openNotes] = await Promise.all([
|
||||
loadOpenPositions(tx),
|
||||
tx.selectFrom("locations").select(["id", "name", "country"]).orderBy("name").execute(),
|
||||
tx
|
||||
.selectFrom("hire_drafts")
|
||||
.select(["id", "step", "payload", "updated_at"])
|
||||
.where("created_by", "=", userId)
|
||||
.orderBy("updated_at", "desc")
|
||||
.execute(),
|
||||
loadOpenNotes(tx),
|
||||
]);
|
||||
|
||||
return { profile, openPositions, locations, drafts, openNotes };
|
||||
});
|
||||
// und damit nebenbei auf einem einheitlichen Lesestand. Was dabei in wie
|
||||
// vielen Rundreisen gelesen wird, steht in lib/shell-data.ts.
|
||||
const data = await withUser(userId, (tx) => loadShellData(tx, userId));
|
||||
|
||||
// `data` ist null, wenn die Person angemeldet, aber nicht freigeschaltet
|
||||
// ist. Ohne den Grund in der Adresse stünde sie vor einer wortlosen
|
||||
// Anmeldeseite und versuchte es endlos erneut.
|
||||
// ist. Hier — und nicht im Proxy — fällt diese Entscheidung: der Proxy hat
|
||||
// keine Datenbankverbindung. Sie wird bei jedem Aufbau frisch gestellt, eine
|
||||
// entzogene Freischaltung wirkt also sofort statt erst mit dem nächsten
|
||||
// Sitzungstoken. Ohne den Grund in der Adresse stünde die Person vor einer
|
||||
// wortlosen Anmeldeseite und versuchte es endlos erneut.
|
||||
if (!data) redirect("/login?error=no_hr_access");
|
||||
|
||||
const userLabel = data.profile.full_name || data.profile.email || "";
|
||||
|
||||
@@ -6,14 +6,12 @@ import { DraftsCard } from "@/components/dashboard/DraftsCard";
|
||||
import { Card, CARD_CLASS, CardTitle } from "@/components/ui/Card";
|
||||
import { actionBadgeStyle } from "@/lib/colors";
|
||||
import { istEingeschraenkt, parseArten, parseZeitraum } from "@/lib/dashboard-filter";
|
||||
import { loadDashboardData } from "@/lib/dashboard-data";
|
||||
import { addDaysIso, fmtDate, fmtName, todayIso } from "@/lib/format";
|
||||
import { divisionOf, loadOrgMaps } from "@/lib/org";
|
||||
import { loadPlacements } from "@/lib/placement";
|
||||
import { loadOpenPositions } from "@/lib/positions";
|
||||
import { divisionOf } from "@/lib/org";
|
||||
import { deriveStatusAsOf } from "@/lib/reports";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
import type { HistoryEventType } from "@/lib/supabase/types";
|
||||
|
||||
// Each KPI carries a colour already; the accent bar repeats it in a second
|
||||
// channel so the tiles are scannable as a row rather than six identical
|
||||
@@ -63,7 +61,6 @@ export default async function DashboardPage({
|
||||
const zeitraum = parseZeitraum(params.tage);
|
||||
const arten = parseArten(params.arten);
|
||||
const bisIso = addDaysIso(today, zeitraum);
|
||||
const zeigt = (art: "hire" | "exit" | "return") => arten.includes(art);
|
||||
|
||||
const userId = await currentUserId();
|
||||
|
||||
@@ -87,114 +84,7 @@ export default async function DashboardPage({
|
||||
upcomingExits,
|
||||
upcomingReturns,
|
||||
history,
|
||||
} = await withUser(userId, async (tx) => {
|
||||
const countIn = (types: readonly HistoryEventType[]) =>
|
||||
tx
|
||||
.selectFrom("employee_history")
|
||||
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
|
||||
.where("event_type", "in", [...types])
|
||||
.where("event_date", ">=", yearStart)
|
||||
.where("event_date", "<=", yearEnd)
|
||||
.executeTakeFirst();
|
||||
|
||||
const [
|
||||
drafts,
|
||||
staffRows,
|
||||
hiresYtd,
|
||||
exitsYtd,
|
||||
openPositions,
|
||||
orgMaps,
|
||||
placements,
|
||||
upcomingHires,
|
||||
upcomingExits,
|
||||
upcomingReturns,
|
||||
history,
|
||||
] = await Promise.all([
|
||||
userId
|
||||
? tx
|
||||
.selectFrom("hire_drafts")
|
||||
.select(["id", "step", "payload", "updated_at"])
|
||||
.where("created_by", "=", userId)
|
||||
.orderBy("updated_at", "desc")
|
||||
.execute()
|
||||
: Promise.resolve([]),
|
||||
|
||||
tx
|
||||
.selectFrom("employees")
|
||||
.select(["id", "weekly_hours", "entry_date", "exit_date", "karenz_start_date", "karenz_return_date"])
|
||||
.orderBy("id")
|
||||
.execute(),
|
||||
|
||||
// Entries/exits count history events, which is what the linked report
|
||||
// counts too. `entry_date` would also sweep up rehires, whose event is
|
||||
// logged as 'Wiedereintritt' — the tile and its destination then showed
|
||||
// different numbers for the same year.
|
||||
countIn(["Eintritt", "Wiedereintritt"]),
|
||||
countIn(["Austritt"]),
|
||||
|
||||
loadOpenPositions(tx),
|
||||
loadOrgMaps(tx),
|
||||
loadPlacements(tx, { asOf: today }),
|
||||
|
||||
// Abgewählte Arten werden gar nicht erst gelesen — die Karte zeigt sie
|
||||
// ohnehin nicht, und eine Abfrage, deren Ergebnis niemand ansieht, ist
|
||||
// eine Abfrage zu viel.
|
||||
zeigt("hire")
|
||||
? tx
|
||||
.selectFrom("employees")
|
||||
.select(["id", "first_name", "last_name", "entry_date"])
|
||||
.where("status", "=", "Geplant")
|
||||
.where("entry_date", ">=", today)
|
||||
.where("entry_date", "<=", bisIso)
|
||||
.execute()
|
||||
: Promise.resolve([]),
|
||||
|
||||
zeigt("exit")
|
||||
? tx
|
||||
.selectFrom("employees")
|
||||
.select(["id", "first_name", "last_name", "exit_date"])
|
||||
.where("exit_date", "is not", null)
|
||||
.where("exit_date", ">=", today)
|
||||
.where("exit_date", "<=", bisIso)
|
||||
.execute()
|
||||
: Promise.resolve([]),
|
||||
|
||||
zeigt("return")
|
||||
? tx
|
||||
.selectFrom("employees")
|
||||
.select(["id", "first_name", "last_name", "karenz_return_date"])
|
||||
.where("status", "=", "Karenz")
|
||||
.where("karenz_return_date", "is not", null)
|
||||
.where("karenz_return_date", ">=", today)
|
||||
.where("karenz_return_date", "<=", bisIso)
|
||||
.execute()
|
||||
: Promise.resolve([]),
|
||||
|
||||
tx
|
||||
.selectFrom("employee_history as h")
|
||||
.leftJoin("employees as e", "e.id", "h.employee_id")
|
||||
.select(["h.id", "h.employee_id", "h.event_date", "h.event_type", "h.description", "e.first_name", "e.last_name"])
|
||||
.orderBy("h.event_date", "desc")
|
||||
.orderBy("h.created_at", "desc")
|
||||
.limit(10)
|
||||
.execute(),
|
||||
]);
|
||||
|
||||
return {
|
||||
drafts,
|
||||
staffRows,
|
||||
hiresYtd: Number(hiresYtd?.anzahl ?? 0),
|
||||
exitsYtd: Number(exitsYtd?.anzahl ?? 0),
|
||||
openPositions,
|
||||
orgMaps,
|
||||
placements,
|
||||
upcomingHires,
|
||||
upcomingExits,
|
||||
upcomingReturns,
|
||||
history,
|
||||
};
|
||||
});
|
||||
|
||||
} = await withUser(userId, (tx) => loadDashboardData(tx, { userId, today, yearStart, yearEnd, bisIso, arten }));
|
||||
// "Aktiv" means status Aktiv — somebody on Karenz is employed but not
|
||||
// active, and is counted by its own tile instead. FTE follows the same
|
||||
// set: Karenz contributes no capacity, so including it would overstate
|
||||
|
||||
Reference in New Issue
Block a user