Collapse sequential query waves, and stop selecting an unshipped column

Against the hosted database a round trip costs about as much as the queries
themselves (~90ms), so page time was dominated by how many waves ran in
sequence rather than by the SQL. Measured with the median of five runs:

- Employee list 244ms -> 101ms. It awaited loadOrgMaps and only then the
  page of employees; the lookup tables are needed to label rows, not to
  build the query, so both now go out together.
- Employee detail 120ms -> 62ms. Nine of the ten queries key off the id
  already in the URL and had no reason to wait for the employee row. The
  manager comes back as an embedded resource on that row instead of a
  follow-up query, which is what makes it one wave rather than two — an
  intermediate version that merely reordered the waves measured *slower*,
  and the embed is the part that actually helps.
- Reports 197ms -> 180ms. Three waves became two. Modest, and worth saying
  so: the snapshot query itself dominates that page, not the wave count.

Also fixes a blank employee list I caused. `absence_type` was added to the
list's explicit column list ahead of its migration, and PostgREST rejects
the *entire* query for one unknown column — so `data` came back null and the
page rendered zero of 809 employees rather than just dropping a chip label.
The column is out of that select until 20260726120000_absence_type.sql is
applied; the detail page selects "*" and shows the kind once it exists.

Verified against the real database rather than by typecheck alone, which is
what would have caught it in the first place.
This commit is contained in:
2026-07-27 08:24:39 +02:00
parent 8282d7f581
commit a9bf439624
3 changed files with 85 additions and 52 deletions

View File

@@ -1,18 +1,28 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { EmployeeDetail } from "@/components/employees/EmployeeDetail"; import { EmployeeDetail } from "@/components/employees/EmployeeDetail";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
import type { Database } from "@/lib/supabase/types";
type PageProps = { params: Promise<{ id: string }> }; type PageProps = { params: Promise<{ id: string }> };
type EmployeeWithManager = Database["public"]["Tables"]["employees"]["Row"] & {
manager: { id: string; first_name: string; last_name: string; job_title: string } | null;
};
export default async function EmployeeDetailPage({ params }: PageProps) { export default async function EmployeeDetailPage({ params }: PageProps) {
const { id } = await params; const { id } = await params;
const supabase = await createClient(); const supabase = await createClient();
const { data: employee } = await supabase.from("employees").select("*").eq("id", id).single(); // Everything here keys off the id already in the URL, and the manager
if (!employee) notFound(); // comes back as an embedded resource on the employee row rather than as a
// follow-up query — so the page is one round trip instead of two. Measured
// against the hosted database that halved the data time (120ms -> 62ms,
// median of five), because a round trip costs more than these queries do.
//
// The hand-written Database type carries no relationship metadata
// (NoRelationships), so the embed is typed at the destructure below.
const [ const [
{ data: manager }, { data: employeeRow },
{ data: directReports }, { data: directReports },
{ data: history }, { data: history },
{ data: dependents }, { data: dependents },
@@ -23,15 +33,14 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
{ data: locations }, { data: locations },
{ data: openPositions }, { data: openPositions },
] = await Promise.all([ ] = await Promise.all([
employee.manager_id supabase.from("employees").select("*, manager:manager_id(id, first_name, last_name, job_title)").eq("id", id).single(),
? supabase.from("employees").select("id, first_name, last_name, job_title").eq("id", employee.manager_id).single() supabase.from("employees").select("id, first_name, last_name, job_title, status").eq("manager_id", id).order("last_name"),
: Promise.resolve({ data: null }),
supabase supabase
.from("employees") .from("employee_history")
.select("id, first_name, last_name, job_title, status") .select("*")
.eq("manager_id", id) .eq("employee_id", id)
.order("last_name"), .order("event_date", { ascending: false })
supabase.from("employee_history").select("*").eq("employee_id", id).order("event_date", { ascending: false }).order("created_at", { ascending: false }), .order("created_at", { ascending: false }),
supabase.from("employee_dependents").select("*").eq("employee_id", id).order("created_at"), supabase.from("employee_dependents").select("*").eq("employee_id", id).order("created_at"),
supabase.from("employee_notes").select("*").eq("employee_id", id).order("created_at", { ascending: false }), supabase.from("employee_notes").select("*").eq("employee_id", id).order("created_at", { ascending: false }),
supabase.from("divisions").select("*").order("name"), supabase.from("divisions").select("*").order("name"),
@@ -41,6 +50,12 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
supabase.from("positions").select("id, position_number, title, team_id, is_lead").eq("status", "open"), supabase.from("positions").select("id, position_number, title, team_id, is_lead").eq("status", "open"),
]); ]);
if (!employeeRow) notFound();
// Split the embedded manager back off so EmployeeDetail keeps receiving a
// plain employees row plus a separate manager, unchanged.
const { manager, ...employee } = employeeRow as EmployeeWithManager;
return ( return (
<EmployeeDetail <EmployeeDetail
employee={employee} employee={employee}

View File

@@ -33,7 +33,6 @@ function pageHref(params: SearchParams, page: number): string {
export default async function EmployeesPage({ searchParams }: EmployeesPageProps) { export default async function EmployeesPage({ searchParams }: EmployeesPageProps) {
const params = await searchParams; const params = await searchParams;
const supabase = await createClient(); const supabase = await createClient();
const orgMaps = await loadOrgMaps(supabase);
const page = Math.max(1, Number(params.page ?? "1") || 1); const page = Math.max(1, Number(params.page ?? "1") || 1);
const from = (page - 1) * PAGE_SIZE; const from = (page - 1) * PAGE_SIZE;
@@ -42,7 +41,12 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
let query = supabase let query = supabase
.from("employees") .from("employees")
.select( .select(
"id, first_name, last_name, personnel_number, job_title, team_id, division_id, location_id, entry_date, employment_type, weekly_hours, status, absence_type", // absence_type is deliberately absent here until
// 20260726120000_absence_type.sql has been applied: PostgREST rejects
// the *whole* query for one unknown column, which turned the entire
// list blank rather than just dropping a chip label. The detail page
// selects "*" and shows the specific kind once the column exists.
"id, first_name, last_name, personnel_number, job_title, team_id, division_id, location_id, entry_date, employment_type, weekly_hours, status",
{ count: "exact" } { count: "exact" }
) )
.order("last_name", { ascending: true }) .order("last_name", { ascending: true })
@@ -59,9 +63,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
} }
if (params.division) query = query.eq("division_id", params.division); if (params.division) query = query.eq("division_id", params.division);
// Comma-separated, so a dashboard tile can link here with the same // Comma-separated, so a dashboard tile can link here with the same
// definition it counted — "aktiv" across this app means Aktiv *and* // status set it counted rather than a narrower one.
// Karenz, and a single-value filter would land the user on a smaller
// number than the tile they clicked.
const statuses = (params.status ?? "") const statuses = (params.status ?? "")
.split(",") .split(",")
.map((s) => s.trim()) .map((s) => s.trim())
@@ -71,7 +73,10 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
query = applyDerivedStatusFilter(query, statuses, todayIso()); query = applyDerivedStatusFilter(query, statuses, todayIso());
if (params.location) query = query.eq("location_id", params.location); if (params.location) query = query.eq("location_id", params.location);
const { data: employeesData, count } = await query; // The org lookup tables are needed only to label the rows, so they load
// alongside the page of employees instead of before it — one round trip
// saved on a page that is otherwise two fast queries.
const [orgMaps, { data: employeesData, count }] = await Promise.all([loadOrgMaps(supabase), query]);
const employees = employeesData ?? []; const employees = employeesData ?? [];
const totalPages = Math.max(1, Math.ceil((count ?? 0) / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil((count ?? 0) / PAGE_SIZE));
@@ -130,7 +135,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
{e.employment_type} · <span className="tabular-nums">{e.weekly_hours}h</span> {e.employment_type} · <span className="tabular-nums">{e.weekly_hours}h</span>
</td> </td>
<td className="px-4 py-2.5"> <td className="px-4 py-2.5">
<StatusChip status={e.status} entryDate={e.entry_date} absenceType={e.absence_type} /> <StatusChip status={e.status} entryDate={e.entry_date} />
</td> </td>
</tr> </tr>
); );

View File

@@ -38,39 +38,65 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
const supabase = await createClient(); const supabase = await createClient();
const mode = parseMode(params.mode); const mode = parseMode(params.mode);
const [{ lookups, divisions, locations }, { data: userRes }] = await Promise.all([loadOrgLookups(supabase), supabase.auth.getUser()]); // Both modes are parsed up front so the data load can start before
// anything is awaited. They group by different dimension sets, so the
// parsed values stay separate rather than being widened into one type.
const asOf = parseIsoDateParam(params.asOf);
const eventGroup = parseEventGroupDimension(params.group);
const eventSplit = parseEventSplitDimension(params.split);
const eventType = parseEventType(params.eventType);
const from = parseEventDateParam(params.from);
const to = parseEventDateParam(params.to);
const measure = parseMeasure(params.measure);
const group = parseGroupDimension(params.group);
const split = parseSplitDimension(params.split);
// The report data depends on neither the org lookups nor on who is signed
// in, so all three go out together. Against a hosted database a round trip
// costs about as much as the query itself, which made this page's three
// sequential waves its dominant cost.
const [{ lookups, divisions, locations }, { data: userRes }, events, employees] = await Promise.all([
loadOrgLookups(supabase),
supabase.auth.getUser(),
mode === "events"
? loadEventHistory(supabase, {
eventType: eventType ?? undefined,
division: params.division,
location: params.location,
from,
to,
})
: Promise.resolve([]),
mode === "snapshot"
? loadSnapshotEmployees(supabase, {
division: params.division,
location: params.location,
status: params.status,
employment: params.employment,
asOf,
})
: Promise.resolve([]),
]);
const user = userRes.user; const user = userRes.user;
// Still a wave of its own: it needs the user id the call above resolves.
const { data: savedReports } = user const { data: savedReports } = user
? await supabase.from("saved_reports").select("id, name, config").eq("created_by", user.id).order("created_at", { ascending: false }) ? await supabase.from("saved_reports").select("id, name, config").eq("created_by", user.id).order("created_at", { ascending: false })
: { data: [] }; : { data: [] };
if (mode === "events") { if (mode === "events") {
const group = parseEventGroupDimension(params.group); const rows = aggregateEvents(events, eventGroup, eventSplit, lookups);
const split = parseEventSplitDimension(params.split);
const eventType = parseEventType(params.eventType);
const from = parseEventDateParam(params.from);
const to = parseEventDateParam(params.to);
const events = await loadEventHistory(supabase, {
eventType: eventType ?? undefined,
division: params.division,
location: params.location,
from,
to,
});
const rows = aggregateEvents(events, group, split, lookups);
const total = sumValues(rows);
return ( return (
<Suspense> <Suspense>
<ReportsPageClient <ReportsPageClient
mode="events" mode="events"
eventGroup={group} eventGroup={eventGroup}
eventSplit={split ?? ""} eventSplit={eventSplit ?? ""}
eventType={eventType ?? ""} eventType={eventType ?? ""}
eventFilters={{ division: params.division ?? "", location: params.location ?? "", from: from ?? "", to: to ?? "" }} eventFilters={{ division: params.division ?? "", location: params.location ?? "", from: from ?? "", to: to ?? "" }}
rows={rows} rows={rows}
total={total} total={sumValues(rows)}
recordCount={events.length} recordCount={events.length}
divisions={divisions} divisions={divisions}
locations={locations} locations={locations}
@@ -80,20 +106,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
); );
} }
const measure = parseMeasure(params.measure);
const group = parseGroupDimension(params.group);
const split = parseSplitDimension(params.split);
const asOf = parseIsoDateParam(params.asOf);
const employees = await loadSnapshotEmployees(supabase, {
division: params.division,
location: params.location,
status: params.status,
employment: params.employment,
asOf,
});
const rows = aggregateReport(employees, measure, group, split, lookups, asOf); const rows = aggregateReport(employees, measure, group, split, lookups, asOf);
const total = totalForRows(rows, measure);
return ( return (
<Suspense> <Suspense>
@@ -110,7 +123,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
employment: params.employment ?? "", employment: params.employment ?? "",
}} }}
rows={rows} rows={rows}
total={total} total={totalForRows(rows, measure)}
recordCount={employees.length} recordCount={employees.length}
divisions={divisions} divisions={divisions}
locations={locations} locations={locations}