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