loopable / web /src /customer-grid /counts.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
609fb78 verified
Raw
History Blame Contribute Delete
19.2 kB
// ---------------------------------------------------------------------------
// customer-grid / counts.ts
// How many records the toolbar claims to be showing β€” and for a SERVER-WINDOWED
// table (CG-2), how it says so honestly.
//
// Its own module rather than a helper inside Toolbar.tsx for two reasons: a
// component file that also exports plain functions breaks React fast refresh,
// and this is the one piece of the toolbar with a testable contract β€” it is
// asserted by aios-web/verify_filter_engine.py alongside the filter engine.
// ---------------------------------------------------------------------------
import type { FilterNode, GridLimit, ScopeCounts, SortSpec } from "./types";
/**
* A whole-book table holds every row, so its own count IS the truth.
*
* A server-windowed table holds one page. Rendering `rows.length` there would report
* "200 records" for a 201,558-row scope β€” a silent `[:N]` wearing a total, which is exactly
* what [[no-unverifiable-aggregates]] forbids. So when scope counts are supplied the label
* states what is on screen AND what it is out of, and keeps the filter distinct from the scope:
*
* whole-book 1,550 records
* windowed showing 200 of 201,558 records
* windowed + filtered showing 200 of 5,728 records (filtered from 201,558)
* window >= matches 5,728 records (filtered from 201,558)
*
* That last case matters: when the whole match set fits in the window nothing is hidden, so
* saying "showing X of Y" would invent a truncation that is not there. The "(filtered from …)"
* clause still appears, because the user narrowing 201,558 rows down to 12 should be told what
* they narrowed from.
*/
export function countLabel(recordCount: number, scope?: ScopeCounts): string {
const n = (v: number) => v.toLocaleString();
if (!scope?.windowed) {
return `${n(recordCount)} ${recordCount === 1 ? "record" : "records"}`;
}
const noun = scope.matched === 1 ? "record" : "records";
const filtered = scope.matched < scope.total ? ` (filtered from ${n(scope.total)})` : "";
if (scope.shown >= scope.matched) return `${n(scope.matched)} ${noun}${filtered}`;
return `showing ${n(scope.shown)} of ${n(scope.matched)} ${noun}${filtered}`;
}
/**
* Wave-7 item W1c (contract C1) β€” the toolbar count while the pool is PARTIAL.
*
* A cold bundle serves the first slice fast and completes in the background; until the
* full payload lands, "1,550 records" would be a silent truncation wearing a total
* ([[no-unverifiable-aggregates]] β€” the same sin the windowed label exists to avoid).
* So the count says what is actually here: `"<loaded> of <total>"`, with `…` standing
* in while the host does not yet know the total. Returns null when the pool is
* complete (absent flag, or partial: false) β€” the caller then renders the ordinary
* count.
*/
export interface PoolProgress {
partial?: boolean;
loaded?: number;
total?: number | null;
}
export function poolProgressLabel(pool: PoolProgress | undefined): string | null {
if (!pool?.partial) return null;
const loaded =
typeof pool.loaded === "number" && Number.isFinite(pool.loaded) && pool.loaded >= 0
? Math.floor(pool.loaded)
: 0;
const total =
typeof pool.total === "number" && Number.isFinite(pool.total) && pool.total > 0
? Math.floor(pool.total).toLocaleString()
: "…";
return `${loaded.toLocaleString()} of ${total}`;
}
/**
* The same count, short enough to survive a narrow toolbar.
*
* MEASURED, not guessed: at the width the grid runs at with the outer nav open the toolbar has
* 796px of visible box, the six controls take 517px and the full windowed label takes 288px β€”
* over budget even with the search box at zero width. So the scope clause has to go, and the
* full string stays available as the element's `title`.
*
* Drops only the "(filtered from N)" clause and the noun. The honest part β€” how many rows are
* on screen out of how many match β€” is exactly what is kept.
*/
export function countLabelCompact(recordCount: number, scope?: ScopeCounts): string {
if (!scope?.windowed) return countLabel(recordCount, scope);
const n = (v: number) => v.toLocaleString();
if (scope.shown >= scope.matched) {
return `${n(scope.matched)} ${scope.matched === 1 ? "record" : "records"}`;
}
return `showing ${n(scope.shown)} of ${n(scope.matched)}`;
}
// ═══════════════════════════════════════════════════════════════════════════════════════════
// ⭐⭐ WAVE 30 Β· W30-T42 (contract C2) β€” THE THREE DECISIONS A WINDOWED GRID MAKES,
// EXTRACTED AS PURE FUNCTIONS SO THEY CAN BE RUN RATHER THAN GREPPED.
// ═══════════════════════════════════════════════════════════════════════════════════════════
//
// Everything below is decided per scroll, per keystroke and per filter chip inside
// `CustomerGrid`, i.e. at CLICK TIME β€” the one place this lane has repeatedly found defects a
// source scan cannot see (W30-T40's fields panel built its body through a render prop; the
// crash was only reachable by opening the panel). A decision that lives in a callback can only
// be asserted by its own text; a decision that lives in an exported function can be FED the
// numbers and checked against its answer. So the callbacks up there hold wiring, and the
// arithmetic lives here, where `gridUx.test.ts` runs it under node.
/** The page size one window request asks for. See `apiBridge.WINDOW_ROWS` β€” this module holds
* no network knowledge, so the caller passes it in. */
export interface WindowScroll {
/**
* How far down the loaded rows the viewport reaches β€” the EXCLUSIVE end of glide's visible
* rectangle (`range.y + range.height`), i.e. the first row index below the fold. Named for
* what the caller passes rather than for a row it points at: it is one PAST the last visible
* row, and a doc that called it "the last row" would be off by one in a reader's head.
*/
lastVisibleRow: number;
/** how many rows this browser holds right now (`ScopeCounts.shown`) */
loaded: number;
/** rows matching the predicate across the WHOLE scope (`ScopeCounts.matched`) */
matched: number;
/** the page size the next request would ask for */
limit: number;
/** how many rows from the end to start fetching. Default 25. */
lead?: number;
}
const DEFAULT_LEAD = 25;
/**
* "Should I fetch another window, and from which offset?" β€” `null` means no.
*
* β›” THE OFFSET IS THE LOADED COUNT, AND THAT IS THE WHOLE CONTRACT WITH THE SERVER. Windows
* are contiguous from 0 under a TOTAL order (`compile_order_by` appends the id as a tiebreak
* precisely so `LIMIT/OFFSET` cannot return one row on two pages), so "how many rows do I
* hold" and "where does the next page start" are the same number. Deriving it from a page
* counter instead would drift the moment one response comes back short.
*
* β›” AND IT REFUSES ON `matched <= loaded`. When the whole match set is already here there is
* nothing to page, and asking anyway would spend a request per scroll event forever against a
* server that answers with an empty window β€” a poll wearing an infinite scroll.
*
* ⚠ `loaded < 1` also refuses: the FIRST window belongs to the load effect, not to a scroll.
* A scroll event that fires before any payload has landed must not race it.
*/
export function nextWindowOffset(s: WindowScroll): number | null {
const { lastVisibleRow, loaded, matched, limit } = s;
if (!Number.isFinite(limit) || limit < 1) return null;
if (!Number.isFinite(loaded) || loaded < 1) return null;
if (!Number.isFinite(matched) || matched <= loaded) return null;
const wanted = Number.isFinite(s.lead as number) ? (s.lead as number) : DEFAULT_LEAD;
const lead = Math.max(1, Math.min(wanted, limit));
if (!Number.isFinite(lastVisibleRow) || lastVisibleRow < loaded - lead) return null;
return loaded;
}
/**
* The sentence under a totals row that was folded over a WINDOW β€” `null` when there is
* nothing to disclose.
*
* β›” THE TICKET'S OWN TRAP, AND WHY THIS IS A DISCLOSURE RATHER THAN A DELETION. `totalsAggs`
* folds `visibleRows`, which in `server-windowed` mode is exactly the rows this browser has
* loaded. The number is therefore CORRECT for a question nobody asked ("the sum of the first
* 200 orders") and wrong for the one the position implies ("the sum of 32,826 orders"). This
* repo's law is that every number drills to rows ([[no-unverifiable-aggregates]]) β€” and the
* loaded rows ARE rows, on screen, scrollable. So the honest fix is to name the denominator,
* not to hide the fold: W30-T42's done-when offers exactly two acceptable states, "reads from
* the server" or "says plainly that it covers the loaded window", and absent is neither.
*
* ⚠ `null` WHEN `loaded >= matched`, and that is not an optimisation. There the loaded rows
* ARE every matching row, so the totals row is a true total and a caveat under it would invent
* a truncation that is not there β€” the same mistake `countLabel` avoids one function up.
*/
export function windowedFoldNote(loaded: number, matched: number): string | null {
if (!Number.isFinite(loaded) || !Number.isFinite(matched)) return null;
if (loaded >= matched) return null;
const n = (v: number) => Math.max(0, Math.floor(v)).toLocaleString();
return `Column totals cover the ${n(loaded)} rows loaded so far, of ${n(matched)} matching.`;
}
/**
* The footer's report of the limits the SERVER declared β€” `{short, full}` or `null`.
*
* β›” R6's SECOND SENTENCE, ON THE CLIENT SIDE OF THE WIRE, AND IT IS THE HALF THAT GETS
* DROPPED. D's route already refuses to truncate silently: it names every limit that binds a
* response with its cause and its recommendation. A client that receives that array and paints
* nothing has re-created the exact violation the ruling forbids β€” the limit is now silent
* again, one layer further out, with a green gate on both sides of the wire.
*
* ⚠ SHORT ON SCREEN, FULL IN THE `title`. The causes are whole sentences (they have to be β€”
* "a number condition is evaluated in SQL at whole-unit precision…" is not compressible into a
* chip) and the strip they land in is 30px tall. `countLabelCompact` already established this
* pattern for the same strip and the same reason: the honest short form on screen, the complete
* statement one hover away. Nothing is dropped; the long half moves.
*/
export function limitSummary(
limits: GridLimit[] | undefined,
counts?: { shown?: number; matched?: number } | null
): { short: string; full: string } | null {
const binding = (limits ?? []).filter(
(l) => l && typeof l.subject === "string" && (l.effect ?? "none") !== "none"
);
if (binding.length === 0) return null;
const full = binding
.map((l) => `${l.subject}: ${l.cause ?? ""}${l.recommendation ? ` β€” ${l.recommendation}` : ""}`)
.join("\n\n");
const short = binding.map((l) => plainLimit(l, counts)).join(" Β· ");
return { short, full };
}
/**
* ⭐⭐ W32-T03 (owner item 14) β€” ONE limit, said the way a person would say it.
*
* The old short form pasted the server's `subject` after a fixed preamble, and `subject` is the
* SERVER'S word for which thing was limited: `offset`, `limit`, `pids`, or a comma-joined list of
* column keys. So a user reading the footer of a 963,783-row grid was told that limits applied
* to **offset** β€” a true sentence in a vocabulary nobody outside `routes_odoo_tables.py` has. Two
* emitters can report one subject, which is how the owner got `pids` printed twice in a row.
*
* ⚠ The banned preamble is DESCRIBED here and never reproduced, deliberately: a literal quoted in
* a comment is what a grep-shaped check finds, and the ticket's own bar is that no surface renders
* it ([[prose-that-becomes-its-own-marker]]).
*
* β›” THE REWRITE IS A REWORD, NEVER A DELETION, AND THAT IS W30/R6's SECOND SENTENCE. *"A limit
* that genuinely cannot be removed must be REPORTED β€” with its cause and a recommended fix β€” never
* silently enforced. A truncation nobody was told about is the violation, not the limit."* So
* `full` (the hover) is untouched, every binding limit still produces a clause, and the `null` case
* is still only "nothing binds".
*
* ⚠ KEYED ON `effect`, NOT ON `subject` OR ON THE PROSE. `effect` is the machine-readable enum the
* server already sets beside every cause; `subject` is free text and `cause` is a paragraph. A
* short form parsed out of `cause` would break the first time somebody improved the wording, and
* this repo has shipped a gate that greps a literal somebody later deleted.
*
* ⚠ AN UNKNOWN `effect` FALLS BACK TO THE CAUSE'S FIRST SENTENCE β€” not to a generic phrase, and
* never to silence. A new emitter that this table has not learned yet must still say something
* true; the failure mode of a lookup table is that the newest limit is the one it cannot describe.
*/
function plainLimit(l: GridLimit, counts?: { shown?: number; matched?: number } | null): string {
const n = (v: number) => Math.max(0, Math.floor(v)).toLocaleString();
const shown = Number.isFinite(counts?.shown as number) ? (counts as { shown: number }).shown : null;
const matched = Number.isFinite(counts?.matched as number)
? (counts as { matched: number }).matched
: null;
switch (l.effect) {
case "window_clamped":
// The done-when's own example. The numbers come from `counts`, which the payload already
// carries for every windowed grid β€” and when it does not, the sentence still names the cause.
return shown !== null && matched !== null && matched > shown
? `Showing ${n(shown)} of ${n(matched)} rows β€” this database is too large to load at once`
: "This database is too large to load at once, so only the first rows are here";
case "slow":
return "Rows this far down load slowly β€” each page is reached by walking every row above it";
case "filter_ignored":
return `Filters are ignored on ${l.subject} β€” this column is not stored in a form the ` +
`server can search`;
case "precision":
return `Number filters on ${l.subject} compare at whole numbers, so a value within half a ` +
`unit can land on the other side`;
case "unresolved":
return "Some rows could not be matched, so this list may be missing a few";
default:
return firstSentence(l.cause) || `A limit applies to ${l.subject}`;
}
}
/** The first sentence of a cause paragraph, trimmed for a 30px strip. Never empty-string-as-answer:
* an unusable cause returns `""` so the caller's `||` can reach its own fallback. */
function firstSentence(cause: string | undefined): string {
const t = String(cause ?? "").trim();
if (!t) return "";
const cut = t.search(/[.;]\s/);
const one = (cut > 0 ? t.slice(0, cut) : t).trim();
return one.length > 120 ? `${one.slice(0, 117).trimEnd()}…` : one;
}
/**
* ⭐⭐ WAVE 30 Β· W30-T42 / R6's SECOND SENTENCE β€” WHAT A WINDOWED GRID CANNOT DO, SAID OUT LOUD.
*
* β›” THE THING THIS EXISTS TO PREVENT, AND IT IS A CLIFF NOBODY HAD WRITTEN DOWN. Turning
* `counts.windowed` on for the orders grid does not only change how rows arrive β€” nine controls
* `CustomerGrid` already gates on `serverWindowed` DISAPPEAR the same instant, every one of them
* with no explanation on screen. They were all working on that grid the day before, because the
* whole table was in the browser. Each refusal is individually correct β€” a cohort built from the
* 200 rows that happen to be in memory is a wrong cohort, an export of one page is a wrong
* export, a group header over a window subtotals the PAGE while claiming to describe the group β€”
* and a person who just watched eight buttons vanish is owed the reason.
*
* That is R6's second sentence exactly: *"if there is lag or it can't be done, you need to
* explicitly tell me why and recommend a fix."* A limit that cannot be removed must be REPORTED
* with its cause and a recommendation, never silently enforced. Silently enforcing this one would
* have been the violation, not the limit.
*
* ⚠ THE LIST IS THE CODE'S, NOT A GUESS: every entry below is a control `CustomerGrid.tsx` turns
* off on `serverWindowed`, and `verify_grid_ux.py` counts the gates so an item cannot be added
* there without a line here.
*/
export const WINDOWED_TOOLS_OFF = [
"grouping and its subtotals",
"the kanban, calendar, list, map and catalog views",
"export",
"add to cohort, and remove from cohort",
"add to folder",
"select from file",
"view alert badges",
];
export function windowedCapabilityNote(matched: number): { short: string; full: string } {
const n = Math.max(0, Math.floor(Number.isFinite(matched) ? matched : 0)).toLocaleString();
return {
short: "Some tools are off on this grid",
full:
`This database is read one page at a time out of ${n} records, so the tools that need `
+ `every matching row in the browser are off here: ${WINDOWED_TOOLS_OFF.join(", ")}. `
+ `Filtering, searching and sorting are NOT affected β€” they run on the server across all `
+ `${n}. Restoring the rest needs the server to do those folds: an export, a cohort `
+ `membership and a group summary computed over the whole match set rather than over the `
+ `page in memory.`,
};
}
/**
* The predicate this browser has asked the SERVER to evaluate, as one comparable string.
*
* β›” WHY A KEY AND NOT A DEEP COMPARE AT THE CALL SITE. In `server-windowed` mode every
* predicate change is a REQUEST, so the effect that fires it must be able to say "this is the
* same question I already asked" β€” and `config.filters` is a fresh array on every render of a
* component that re-renders on hover. Keyed on the VALUE, the effect fires once per real
* change; keyed on identity it would fire per repaint, which is a paid round trip per mouse
* move on a 963,783-row grid.
*
* ⚠ ONE PLACE BUILDS BOTH THE KEY AND THE EMPTINESS TEST (`EMPTY_WINDOW_PREDICATE` below is
* this function's own answer for a blank view). Two spellings of "no predicate" is how the
* first window gets re-requested on every mount for nothing.
*/
export function windowPredicateKey(
filters: FilterNode[] | undefined,
filterConj: string | undefined,
sorts: SortSpec | undefined,
search: string | undefined
): string {
return JSON.stringify([
filters ?? [],
filterConj === "or" ? "or" : "and",
sorts ?? [],
(search ?? "").trim(),
]);
}
/** What `windowPredicateKey` answers for a view with no filter, no sort and no search β€” i.e.
* exactly the question the FIRST window (offset 0, no query args) already answered. */
export const EMPTY_WINDOW_PREDICATE = windowPredicateKey([], "and", [], "");