// --------------------------------------------------------------------------- // pages / PageView.tsx — EXIT wave 2 (W2-7, contracts Y1 + Y2 + Y3). // // THE renderer. A Y1 envelope goes in, a page comes out — and it knows the name // of no page. That is the whole bet of the wave: wave 3 ships Collections and // Procurement by adding a server-side builder and a registry key, not a // component tree, and this file is where that promise is either kept or broken. // If it ever grows `if (page.key === …)`, the envelope has stopped paying for // itself and it belongs in the doc as an amendment, not in a branch here. // // TWO RULES ARE STRUCTURAL, NOT STYLISTIC: // · **Blocks render IN ORDER** (Y1 rule 7). The server decides the narrative; // the client does not re-sort it into what it thinks reads better. // · **An unknown block type is SKIPPED, never fatal.** That is the property // that lets the server learn a block type before this client does — a // deployment ordering the wave will actually hit. // --------------------------------------------------------------------------- import { DashHeader, DataTable, KpiRow, Section, ValidationPanel } from "../ui"; import type { DrillDescriptor, OnOpen } from "../ui"; import type { ChartBlock, DecompDescriptor, KpisBlock, PageBlock, PageControl, PageEnvelope, SectionBlock, TableBlock, ValidationBlock, } from "../ui/types"; import { isRowDrillRule } from "../ui/types"; import { SeriesChart } from "../viz/SeriesChart"; import type { ChartSpec } from "../viz/chartData"; import type { Row } from "../viz/types"; /** A `section` block labels what FOLLOWS it, so the renderer pairs each one * with the blocks up to the next section. Without this the note and the table * it explains are two unrelated slabs with a gap between them. */ interface Group { head?: SectionBlock; body: PageBlock[]; } function group(blocks: PageBlock[]): Group[] { const out: Group[] = []; let cur: Group = { body: [] }; for (const b of blocks) { if (b.type === "section") { if (cur.head || cur.body.length) out.push(cur); cur = { head: b as SectionBlock, body: [] }; } else { cur.body.push(b); } } if (cur.head || cur.body.length) out.push(cur); return out; } function ChartBlockView({ block, onOpen }: { block: ChartBlock; onOpen?: OnOpen }) { const rule = block.drill; const rowByX = new Map(); if (isRowDrillRule(rule)) { const xKey = (block.spec as { x?: string }).x; for (const r of block.rows) { const d = r[rule.row_key]; const x = xKey ? r[xKey] : undefined; if (x != null && d && typeof d === "object" && (d as DecompDescriptor).kind === "decomp") { rowByX.set(String(x), d as DecompDescriptor); } } } // The server-sent per-bucket delta (the printed YoY %). A null stays absent — // the partial period carries no label, by the server's own rule. const deltaByX = new Map(); if (block.delta_key) { const xKey = (block.spec as { x?: string }).x; for (const r of block.rows) { const x = xKey ? r[xKey] : undefined; const v = r[block.delta_key]; if (x != null && typeof v === "number" && Number.isFinite(v)) deltaByX.set(String(x), v); } } const pickable = !!onOpen && rowByX.size > 0; return ( <> { const d = rowByX.get(x); if (d && onOpen) onOpen(d); }} /> {/* Y1 rule 6 — even a chart discloses its cap. A trend that quietly drew 12 of 18 months is the same defect as a truncated table. */} {block.shown != null && block.total != null && block.total > block.shown ? (

Showing {block.shown} of {block.total} periods

) : null} ); } function BlockView({ block, onOpen }: { block: PageBlock; onOpen?: OnOpen }) { switch (block.type) { case "kpis": return ; case "table": { const t = block as TableBlock; return ( ); } case "chart": return ; case "validation": return ; default: // ⛔ Y1 rule 7. Not an error, not a placeholder, not a console warning the // user cannot see — nothing. A block type this client does not know is a // server that is ahead of it, which is the normal state during a rollout. return null; } } export function PageView({ page, onControl, onOpen, busy, }: { page: PageEnvelope; onControl?: (key: string, value: string) => void; onOpen?: (d: DrillDescriptor) => void; busy?: boolean; }) { const groups = group(page.blocks); return (
{groups.map((g, i) => g.head ? (
{g.body.map((b, j) => ( ))}
) : (
{g.body.map((b, j) => ( ))}
) )}
); }