File size: 3,982 Bytes
092334a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
// ---------------------------------------------------------------------------
// ui / blocks.ts β€” EXIT wave 2 (W2-6/W2-9). The block RULES, pure and
// React-free so `verify_ui.py` can run them under bare node.
//
// The two rules in this file are the two owner constants the presentation layer
// is most able to break quietly, which is exactly why they live here rather
// than inline in a component: a rule inside JSX can only be checked by looking
// at a screenshot, and both of these fail in ways a screenshot looks fine for.
//
//   1. **No silent caps.** `capDisclosure` renders from the SERVER's numbers.
//   2. **One drill grammar.** `cellDescriptor` resolves a row to a descriptor
//      or to nothing β€” never to a half-populated one.
// ---------------------------------------------------------------------------

import { int } from "./fmt";
import { isRowDrillRule } from "./types";
import type { DecompDescriptor, DrillDescriptor, DrillRule, EntityKind, PageRow } from "./types";

export interface CapDisclosure {
  text: string;
  /** True when rows were actually withheld β€” the disclosure is louder then. */
  capped: boolean;
}

/**
 * "Showing N of M", or nothing at all.
 *
 * β›” THE NUMBERS ARE THE SERVER'S AND ARE NEVER DERIVED FROM `rows.length`.
 * That is the whole point and it is subtle: a disclosure computed from the
 * array it is describing agrees with a truncated list BY CONSTRUCTION, so it
 * would read "Showing 25 of 25" over a list the server cut from 812. It cannot
 * be wrong, which means it is not a check β€” it is decoration that looks like
 * one ([[no-unverifiable-aggregates]]: a silent `[:N]` cap is a defect).
 *
 * Y1 rule 6 ships BOTH numbers even when they are equal, so `shown === total`
 * still renders β€” "Showing 12 of 12" is the sentence that tells a reader
 * nothing was withheld, and its absence is not the same statement.
 */
export function capDisclosure(shown?: number, total?: number): CapDisclosure | null {
  if (typeof shown !== "number" || typeof total !== "number") return null;
  if (!Number.isFinite(shown) || !Number.isFinite(total)) return null;
  return { text: `Showing ${int(shown)} of ${int(total)}`, capped: total > shown };
}

/**
 * A row + a block-level drill rule β†’ the descriptor to open, or `undefined`.
 *
 * Y1 rule 5's placements (b) and (c):
 *   (b) an ENTITY rule names the columns holding the id and the label;
 *   (c) a ROW rule names the column holding a complete `DecompDescriptor`.
 *
 * ⚠ A DESCRIPTOR IS COMPLETE OR IT DOES NOT EXIST. A half-populated one opens a
 * panel onto the wrong window β€” a period drill with no `date_from` would show
 * the reader a number attributed to a scope nobody computed. Returning
 * `undefined` costs a click; returning a partial costs the number's credibility.
 */
export function cellDescriptor(
  row: PageRow,
  rule: DrillRule | undefined
): DrillDescriptor | undefined {
  if (!rule) return undefined;
  if (isRowDrillRule(rule)) {
    const d = row[rule.row_key];
    if (!d || typeof d !== "object") return undefined;
    const dd = d as DecompDescriptor;
    if (dd.kind !== "decomp") return undefined;
    // The window is what makes a decomp descriptor answerable at all.
    if (!dd.date_from || !dd.date_to) return undefined;
    return dd;
  }
  const id = row[rule.id_key];
  if (id == null || id === "") return undefined;
  const label = rule.label_key ? row[rule.label_key] : undefined;
  return {
    kind: rule.kind as EntityKind,
    id: id as string | number,
    label: String(label ?? id),
  };
}

/** The empty-state sentence. NEVER the bare words "no data": an empty table
 *  that does not say what empty MEANS reads as a broken page, and sends people
 *  looking in the wrong place. The server supplies the meaning (`empty`); this
 *  is the fallback when it did not. */
export function emptyText(empty?: string): string {
  const s = (empty ?? "").trim();
  return s || "Nothing matched this scope.";
}