| // --------------------------------------------------------------------------- | |
| // 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."; | |
| } | |