perf(dashboard2): consolidate 14 PB count queries into 1 fetch

Single getList pulls every quote this rep created in the last 62 days,
then buckets client-side into the 6 day/week/month windows plus the
8-week trend. Also fans out the 4 remaining queries (outstandingQuotes,
outstandingOrderItems probe, monthRevenue, the consolidated quotes
fetch) in parallel via Promise.all.

Query count: 18 → 5. Page load bounded by the slowest query instead of
the sum. The widest quote window is "last month" (~62 days), so that
single bound covers every other day/week/month window for bucketing.
Cap at 2000 rows to defend against runaway responses for very active
reps.
This commit is contained in:
Dave 2026-09-01 02:08:52 +02:00
parent dc8436e832
commit 1fc3580aed

View file

@ -9,6 +9,15 @@ const QUOTE_VOLUME_WEEKS = 8;
const OUTSTANDING_LIST_CAP = 8;
const OUTSTANDING_ORDER_ITEMS_CAP = 200;
const MONTH_ORDER_ITEM_LIMIT = 1000;
// Quote-volume trend + the 6 day/week/month windows are all bucketed
// from a single fetch — the widest window is "last month" (~62 days),
// so this bound covers every other window. For a single rep this is a
// small slice; even busy reps rarely exceed a few hundred quotes over
// 2 months.
const QUOTE_FETCH_LOOKBACK_MS = 62 * 24 * 60 * 60 * 1000;
// Hard cap on the single fetch in case a rep really has thousands of
// quotes in 2 months — protects the page from a runaway response.
const QUOTE_FETCH_LIMIT = 2000;
type AdminPocketBase = Awaited<ReturnType<typeof getAdminPocketBase>>;
@ -71,21 +80,13 @@ function esc(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function rangeFilter(field: string, start: Date, end: Date): string {
return `${field} >= "${start.toISOString()}" && ${field} < "${end.toISOString()}"`;
}
/** Count-only query — reads PB's own `totalItems` via a `perPage=1` page. */
async function countWhere(
pb: AdminPocketBase,
collection: string,
filter: string,
): Promise<number> {
const result = await pb.collection(collection).getList(1, 1, {
filter,
fields: "id",
});
return result.totalItems || 0;
/** Build the 8 week-start dates for the trend chart, oldest first. */
function buildWeekStarts(thisWeekStart: Date): Date[] {
const out: Date[] = [];
for (let i = QUOTE_VOLUME_WEEKS - 1; i >= 0; i--) {
out.push(addDaysUtc(thisWeekStart, -7 * i));
}
return out;
}
export async function getRepDashboardData(
@ -99,7 +100,7 @@ export async function getRepDashboardData(
const repFilter = `customer.rep = "${esc(repId)}"`;
const orderRepFilter = `order.customer.rep = "${esc(repId)}"`;
/* ---------- quotesLoaded (6 count-only queries) ---------- */
/* ---------- date boundaries (UTC) ---------- */
const todayStart = startOfDayUtc(now);
const yesterdayStart = addDaysUtc(todayStart, -1);
const tomorrowStart = addDaysUtc(todayStart, 1);
@ -109,53 +110,36 @@ export async function getRepDashboardData(
const thisMonthStart = startOfMonthUtc(now);
const lastMonthStart = addMonthsUtc(thisMonthStart, -1);
const nextMonthStart = addMonthsUtc(thisMonthStart, 1);
const quoteWindowStart = new Date(now.getTime() - QUOTE_FETCH_LOOKBACK_MS);
const todayStartMs = todayStart.getTime();
const yesterdayStartMs = yesterdayStart.getTime();
const thisWeekStartMs = thisWeekStart.getTime();
const lastWeekStartMs = lastWeekStart.getTime();
const thisMonthStartMs = thisMonthStart.getTime();
const lastMonthStartMs = lastMonthStart.getTime();
const nextMonthStartMs = nextMonthStart.getTime();
const nextWeekStartMs = nextWeekStart.getTime();
const quoteBaseFilter = `${repFilter} && is_template != true`;
const weekStarts = buildWeekStarts(thisWeekStart);
const [
todayCount,
yesterdayCount,
thisWeekCount,
lastWeekCount,
thisMonthCount,
lastMonthCount,
] = await Promise.all([
countWhere(pb, QUOTES_COLLECTION, `${quoteBaseFilter} && ${rangeFilter("created", todayStart, tomorrowStart)}`),
countWhere(pb, QUOTES_COLLECTION, `${quoteBaseFilter} && ${rangeFilter("created", yesterdayStart, todayStart)}`),
countWhere(pb, QUOTES_COLLECTION, `${quoteBaseFilter} && ${rangeFilter("created", thisWeekStart, nextWeekStart)}`),
countWhere(pb, QUOTES_COLLECTION, `${quoteBaseFilter} && ${rangeFilter("created", lastWeekStart, thisWeekStart)}`),
countWhere(pb, QUOTES_COLLECTION, `${quoteBaseFilter} && ${rangeFilter("created", thisMonthStart, nextMonthStart)}`),
countWhere(pb, QUOTES_COLLECTION, `${quoteBaseFilter} && ${rangeFilter("created", lastMonthStart, thisMonthStart)}`),
]);
/* ---------- quoteVolumeTrend (8 weekly count-only queries) ---------- */
const weekStarts: Date[] = [];
for (let i = QUOTE_VOLUME_WEEKS - 1; i >= 0; i--) {
weekStarts.push(addDaysUtc(thisWeekStart, -7 * i));
}
const quoteVolumeTrend = await Promise.all(
weekStarts.map(async (weekStart) => {
const weekEnd = addDaysUtc(weekStart, 7);
const count = await countWhere(
pb,
QUOTES_COLLECTION,
`${quoteBaseFilter} && ${rangeFilter("created", weekStart, weekEnd)}`,
);
return { weekStart: weekStart.toISOString().slice(0, 10), count };
}),
);
/* ---------- outstandingQuotes, outstandingOrders probe, monthRevenue ----------
* These three queries are independent — fan them out in parallel so the
* page load is bounded by the slowest one (~80-150ms) instead of their
* sum (~300-500ms). The follow-up `orders` fetch for outstandingOrders
* stays sequential because it needs the deduped order ids from the probe.
/* ---------- 4 independent queries in parallel ----------
* 1. quotesLoaded + quoteVolumeTrend (single fetch, bucket client-side)
* 2. outstandingQuotes (capped list)
* 3. outstandingOrderItems probe (capped at 200 items)
* 4. monthRevenue (covers both months in one fetch)
*/
const [
recentQuotesResult,
outstandingQuotesResult,
outstandingOrderItems,
monthOrderItems,
] = await Promise.all([
pb.collection(QUOTES_COLLECTION).getList<{ created?: string }>(1, QUOTE_FETCH_LIMIT, {
filter: `${quoteBaseFilter} && created >= "${quoteWindowStart.toISOString()}"`,
fields: "id,created",
}),
pb.collection(QUOTES_COLLECTION).getList(1, OUTSTANDING_LIST_CAP, {
filter: `${repFilter} && (status = "new" || status = "claimed") && is_template != true`,
sort: "-created",
@ -178,6 +162,35 @@ export async function getRepDashboardData(
}),
]);
/* ---------- bucket quotesLoaded + quoteVolumeTrend from the single fetch ---------- */
let todayCount = 0;
let yesterdayCount = 0;
let thisWeekCount = 0;
let lastWeekCount = 0;
let thisMonthCount = 0;
let lastMonthCount = 0;
const weekBuckets = new Array<number>(QUOTE_VOLUME_WEEKS).fill(0);
for (const q of recentQuotesResult.items) {
if (!q.created) continue;
const ms = new Date(q.created).getTime();
if (ms >= todayStartMs) todayCount++;
else if (ms >= yesterdayStartMs) yesterdayCount++;
if (ms >= thisWeekStartMs && ms < nextWeekStartMs) thisWeekCount++;
else if (ms >= lastWeekStartMs && ms < thisWeekStartMs) lastWeekCount++;
if (ms >= thisMonthStartMs && ms < nextMonthStartMs) thisMonthCount++;
else if (ms >= lastMonthStartMs && ms < thisMonthStartMs) lastMonthCount++;
// 8-week trend: index 0 = current week, 1 = last week, ... 7 = 7 weeks ago
const weeksAgo = Math.floor((thisWeekStartMs - ms) / (7 * 24 * 60 * 60 * 1000));
if (weeksAgo >= 0 && weeksAgo < QUOTE_VOLUME_WEEKS) {
weekBuckets[weeksAgo]++;
}
}
const quoteVolumeTrend = weekStarts.map((weekStart, i) => ({
weekStart: weekStart.toISOString().slice(0, 10),
count: weekBuckets[i],
}));
/* ---------- outstandingQuotes ---------- */
const outstandingQuotes = {
total: outstandingQuotesResult.totalItems || 0,
@ -230,7 +243,6 @@ export async function getRepDashboardData(
/* ---------- monthRevenue ---------- */
let sellingThisMonth = 0;
let sellingLastMonth = 0;
const thisMonthStartMs = thisMonthStart.getTime();
for (const it of monthOrderItems.items as unknown as Array<{
created?: string;
quantity?: number | null;
@ -239,12 +251,10 @@ export async function getRepDashboardData(
}>) {
const qty = Number(it.quantity ?? it.order_qty ?? 0);
const lineTotal = Number(it.selling_price ?? 0) * qty;
// `created` is an ISO string — compare on its parsed timestamp so we
// don't tie this to a particular date-string format PB happens to use.
const createdMs = it.created ? new Date(it.created).getTime() : 0;
if (createdMs >= thisMonthStartMs) {
const ms = it.created ? new Date(it.created).getTime() : 0;
if (ms >= thisMonthStartMs && ms < nextMonthStartMs) {
sellingThisMonth += lineTotal;
} else {
} else if (ms >= lastMonthStartMs && ms < thisMonthStartMs) {
sellingLastMonth += lineTotal;
}
}