updates on the ui changes and changed into doormile
This commit is contained in:
145
src/lib/assistant/scan.js
Normal file
145
src/lib/assistant/scan.js
Normal file
@@ -0,0 +1,145 @@
|
||||
import { getBookingsPage } from '@/api/doormile';
|
||||
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||
|
||||
/**
|
||||
* Reading bookings for the assistant.
|
||||
*
|
||||
* `GET /admin/bookings` has no date, status or tenant filter and caps `pagesize`
|
||||
* at 1000 server-side, so every question is answered by draining pages and
|
||||
* filtering here. A single `getBookings(1, 1000)` silently under-reports the
|
||||
* moment an account passes 1000 lifetime bookings, which is why nothing in the
|
||||
* assistant calls it.
|
||||
*
|
||||
* Every scan returns `{ rows, truncated, scanned, pagesFetched, total }` rather
|
||||
* than a bare array, and **`truncated` is not optional to handle**. A count
|
||||
* built on a capped scan is a floor, not a total — run it through `countPhrase`,
|
||||
* append `truncationNote` to the detail, and build the audit entry with
|
||||
* `scanCall`, which reports an error rather than a green tick beside a partial
|
||||
* number.
|
||||
*/
|
||||
|
||||
const BULK_PAGESIZE = 1000;
|
||||
|
||||
/** 12k rows — generous, but bounded so one question cannot hammer the API. */
|
||||
const MAX_PAGES = 12;
|
||||
|
||||
/**
|
||||
* Row order is not documented. When page 1 comes back newest-first the scan can
|
||||
* stop as soon as a page ends older than the window; otherwise it scans to the
|
||||
* budget. Detected per call rather than assumed, so a backend change degrades to
|
||||
* "scan everything" — slower, still correct — rather than to a wrong answer.
|
||||
*/
|
||||
const isDescendingByCreatedAt = (rows) => {
|
||||
if (rows.length < 2) return false;
|
||||
const first = parseDoormileTimestamp(rows[0].createdat);
|
||||
const last = parseDoormileTimestamp(rows[rows.length - 1].createdat);
|
||||
return first.isValid() && last.isValid() && first.valueOf() > last.valueOf();
|
||||
};
|
||||
|
||||
const inRange = (booking, start, end) => {
|
||||
const at = parseDoormileTimestamp(booking.createdat);
|
||||
if (!at.isValid()) return false;
|
||||
const day = at.format('YYYY-MM-DD');
|
||||
return day >= start && day <= end;
|
||||
};
|
||||
|
||||
/**
|
||||
* A short-lived page cache.
|
||||
*
|
||||
* A multi-part question runs several intents, and a comparison runs two ranges —
|
||||
* each would otherwise re-drain the same pages. Deliberately small and local:
|
||||
* this is not a caching layer to settle on.
|
||||
*/
|
||||
const PAGE_CACHE_TTL_MS = 20_000;
|
||||
const pageCache = new Map();
|
||||
|
||||
const getPageCached = (page) => {
|
||||
const hit = pageCache.get(page);
|
||||
if (hit && Date.now() - hit.at < PAGE_CACHE_TTL_MS) return hit.promise;
|
||||
|
||||
const promise = getBookingsPage(page, BULK_PAGESIZE).catch((err) => {
|
||||
/* Never cache a failure — the next question should retry, not inherit it. */
|
||||
pageCache.delete(page);
|
||||
throw err;
|
||||
});
|
||||
pageCache.set(page, { at: Date.now(), promise });
|
||||
return promise;
|
||||
};
|
||||
|
||||
export const fetchBookingsInRange = async (start, end) => {
|
||||
const firstPage = await getPageCached(1);
|
||||
const total = firstPage.total;
|
||||
const pageCount = Math.max(1, Math.ceil(total / BULK_PAGESIZE));
|
||||
const budget = Math.min(pageCount, MAX_PAGES);
|
||||
|
||||
const collected = [...firstPage.rows];
|
||||
const descending = isDescendingByCreatedAt(firstPage.rows);
|
||||
|
||||
/* Newest-first and this page already ends before the window opens → every
|
||||
later page is older still, so there is nothing left to find. */
|
||||
const pageEndsBeforeRange = (rows) => {
|
||||
if (!descending || !rows.length) return false;
|
||||
const oldest = parseDoormileTimestamp(rows[rows.length - 1].createdat);
|
||||
return oldest.isValid() && oldest.format('YYYY-MM-DD') < start;
|
||||
};
|
||||
|
||||
let stoppedEarly = pageEndsBeforeRange(firstPage.rows);
|
||||
let lastPageFetched = 1;
|
||||
|
||||
for (let page = 2; page <= budget && !stoppedEarly; page += 1) {
|
||||
const next = await getPageCached(page);
|
||||
lastPageFetched = page;
|
||||
if (!next.rows.length) {
|
||||
stoppedEarly = true;
|
||||
break;
|
||||
}
|
||||
collected.push(...next.rows);
|
||||
stoppedEarly = pageEndsBeforeRange(next.rows);
|
||||
}
|
||||
|
||||
return {
|
||||
rows: collected.filter((booking) => inRange(booking, start, end)),
|
||||
/* Truncated only if the budget ran out with pages still unread AND the scan
|
||||
did not stop early because it had already passed the window. */
|
||||
truncated: !stoppedEarly && pageCount > budget,
|
||||
scanned: collected.length,
|
||||
pagesFetched: lastPageFetched,
|
||||
total,
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchBookingsForDay = (day) => fetchBookingsInRange(day, day);
|
||||
|
||||
/**
|
||||
* A full scan with no date window — for a per-order lookup, which has to look
|
||||
* everywhere rather than inside a range. The sentinel bounds keep one code path:
|
||||
* they can never trigger the early stop, so this always drains to the page
|
||||
* budget and reports `truncated` honestly if the id could be further back.
|
||||
*/
|
||||
export const scanBookings = () => fetchBookingsInRange('0000-01-01', '9999-12-31');
|
||||
|
||||
/** A count over a truncated scan is a floor — say so. */
|
||||
export const countPhrase = (scan, n) => `${scan.truncated ? 'At least ' : ''}${n}`;
|
||||
|
||||
export const truncationNote = (scan) =>
|
||||
scan.truncated
|
||||
? `\n\nScanned the most recent ${scan.scanned.toLocaleString('en-IN')} of ${scan.total.toLocaleString(
|
||||
'en-IN'
|
||||
)} bookings — this is a floor, not a complete count.`
|
||||
: '';
|
||||
|
||||
/**
|
||||
* The audit entry for a scan. Reports the real page count, and flags itself as
|
||||
* an error when truncated so the sources strip cannot show a green "complete"
|
||||
* beside a partial number.
|
||||
*/
|
||||
export const scanCall = (scan, note) => ({
|
||||
name: 'getBookingsPage',
|
||||
target: `/admin/bookings (${scan.pagesFetched} page${scan.pagesFetched === 1 ? '' : 's'} × ${BULK_PAGESIZE})`,
|
||||
status: scan.truncated ? 'error' : 'complete',
|
||||
errorMessage: scan.truncated ? `Scan capped at ${MAX_PAGES} pages; ${scan.total} bookings exist` : undefined,
|
||||
stats: note,
|
||||
});
|
||||
|
||||
/** A plain audit entry for a non-scan call. */
|
||||
export const call = (name, target, stats) => ({ name, target, status: 'complete', stats });
|
||||
Reference in New Issue
Block a user