File size: 19,238 Bytes
092334a e1b3e71 092334a e1b3e71 609fb78 e1b3e71 609fb78 e1b3e71 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 | // ---------------------------------------------------------------------------
// 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", [], "");
|