| // --------------------------------------------------------------------------- | |
| // 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", [], ""); | |