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:
parent
dc8436e832
commit
1fc3580aed
1 changed files with 70 additions and 60 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue