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