File size: 6,213 Bytes
092334a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c9d432b
 
 
 
 
 
 
 
 
 
 
092334a
 
 
 
 
 
 
 
 
c9d432b
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
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
// ---------------------------------------------------------------------------
// 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>
  );
}