loopable / web /src /pages /PageView.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
c9d432b verified
Raw
History Blame Contribute Delete
6.21 kB
// ---------------------------------------------------------------------------
// 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<string, DecompDescriptor>();
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<string, number>();
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 (
<>
<SeriesChart
spec={block.spec as unknown as ChartSpec}
series={block.series ?? []}
fields={block.fields}
rows={block.rows as unknown as Row[]}
xOrder={block.x_order}
deltaByKey={deltaByX.size ? deltaByX : undefined}
pickable={pickable}
onPick={(x) => {
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 ? (
<p className="pg-shown is-capped">
Showing {block.shown} of {block.total} periods
</p>
) : null}
</>
);
}
function BlockView({ block, onOpen }: { block: PageBlock; onOpen?: OnOpen }) {
switch (block.type) {
case "kpis":
return <KpiRow items={(block as KpisBlock).items} onOpen={onOpen} />;
case "table": {
const t = block as TableBlock;
return (
<DataTable
columns={t.columns}
rows={t.rows}
drill={t.drill}
shown={t.shown}
total={t.total}
empty={t.empty}
download={t.download}
onOpen={onOpen}
/>
);
}
case "chart":
return <ChartBlockView block={block as ChartBlock} onOpen={onOpen} />;
case "validation":
return <ValidationPanel checks={(block as ValidationBlock).checks} />;
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 (
<div className="pg-page">
<DashHeader
title={page.title}
asOf={page.as_of}
subtitle={page.subtitle}
controls={page.controls as PageControl[] | undefined}
onControl={onControl}
busy={busy}
/>
{groups.map((g, i) =>
g.head ? (
<Section key={g.head.key ?? `s${i}`} label={g.head.label} note={g.head.note}>
{g.body.map((b, j) => (
<BlockView key={b.key ?? `b${j}`} block={b} onOpen={onOpen} />
))}
</Section>
) : (
<div key={`g${i}`}>
{g.body.map((b, j) => (
<BlockView key={b.key ?? `b${j}`} block={b} onOpen={onOpen} />
))}
</div>
)
)}
</div>
);
}