// --------------------------------------------------------------------------- // viz / DashboardView.tsx (was customer-grid/DashboardView.tsx — EXIT wave 2, // W2-5/Y3: a MOVE. Only the import lines and `ChartCard`'s export changed.) // // Wave-8 I19c (contract C2) — the Dashboard display mode: a per-view board of // charts computed from the SAME pipeline rows the grid paints, so the filters, // the search and the cohort scope apply by construction. Change a condition and // every chart moves with the grid's count, because it IS the grid's count. // // Rendering crosses one adapter boundary into Vega-Lite. Saved views and the // arithmetic remain renderer-neutral, so the renderer can be replaced without // migrating persisted chart definitions. // // Honesty (rule 8b) is rendered, not just computed: every card states its // denominator, says when it dropped buckets past the cap and what they were // worth, counts blank groups as a real "(blank)" bucket, and names the rows an // average could not use. A chart that cannot be drawn says WHY in plain words // instead of rendering an empty frame that reads as "no data". // --------------------------------------------------------------------------- import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react"; import type { Field, Row } from "./types"; import { CHART_AGGS, CHART_AGG_LABELS, CHART_FORMATS, CHART_H_RANGE, CHART_W_RANGE, CHART_KINDS, CHART_PALETTES, MAX_AXIS_LABEL, MAX_BUCKETS, MAX_CHARTS, chartModel, PERIOD_KINDS, chartValueText, defaultChart, fieldOptions, groupableForChart, isMetricKey, isPeriodChart, measurableForChart, } from "./chartData"; // Wave-16 C-CHARTCAP — the doors to wave-15's engines: the table kind's data and the KPI // card's delta line. Pure assemblers, so `verify_charts` drives the same functions this // component renders. import { kpiYoyFromSeries, tableFromSpec } from "./salesParity"; import type { GroupTable, KpiTile } from "./salesParity"; import type { ChartAgg, ChartAxisSide, ChartModel, ChartFormat, ChartKind, ChartPalette, ChartSpec, } from "./chartData"; import { toVegaLiteSpec } from "./vega/toVegaLiteSpec"; // ⚠ Still customer-grid's (Y3 amendment, 2026-07-30): the icon layer is ONE // system with its own gate (verify_icons.py's token-parity leg), and a second // set of chart-kind glyphs is exactly the drift that gate exists to catch. import { ChartKindIcon } from "../customer-grid/icons"; // R3 — the trend path. The SAME channel the time-series view uses, deliberately: two // implementations of "sales by month" that could disagree is the thing worth avoiding here, // not the shared import. `TS_BUCKETS` is a value (a vocabulary), not a layering violation — // viz already imports the grid's icon layer for the identical "one system, one gate" reason. import { fetchTimeseries } from "../customer-grid/apiBridge"; import type { SurfaceScope } from "../customer-grid/apiBridge"; import { DEFAULT_TS_LAST_N, TS_YOY_BACK, buildTsRequest, buildTsTable, tsCompareChartModel, tsRowToChartModel, } from "../customer-grid/timeSeriesData"; import type { TsPayload } from "../customer-grid/timeSeriesData"; import { TS_BUCKETS } from "../customer-grid/types"; import type { TsBucket } from "../customer-grid/types"; import "./DashboardView.css"; const VegaLiteChart = lazy(() => import("./vega/VegaLiteChart")); /** * Wave-9 I11 (C2) — what each palette NAME means, in the user's words. The stored value is a * colour JOB, never a hex: a browser must not be able to post a literal, or a tenant restyle * is defeated by whatever somebody saved last year. * ⚠ STATUS colours are deliberately not offered — they are reserved signal, and reusing them * as "series 4" is how a chart starts lying. */ /** The board is a 12-column grid; a new card takes half of it. */ export const CHART_BOARD_COLS = 12; const DEFAULT_CHART_W = 6; function clampRange(n: number, [lo, hi]: readonly [number, number]): number { return Math.max(lo, Math.min(hi, n)); } const PALETTE_LABELS: Record = { brand: "Brand", categorical: "Distinct categories", sequential: "Low to high", diverging: "Below / above a midpoint", }; const FORMAT_LABELS: Record = { auto: "Automatic", number: "Plain number", currency: "Currency", percent: "Percent", compact: "Compact (1.2k)", }; /** * Merge one side of the axis spec, dropping keys that have been emptied so a cleared label * does not persist as `""` — the host trims and drops it anyway, and a spec that disagrees * with what the host stored is a spec that churns on every echo. */ function withAxis( spec: ChartSpec, side: "x" | "y", patch: Partial ): ChartSpec { const merged: ChartAxisSide = { ...(spec.axis?.[side] ?? {}), ...patch }; if (!merged.label?.trim()) delete merged.label; if (merged.format === "auto") delete merged.format; const axis = { ...(spec.axis ?? {}) }; if (merged.label === undefined && merged.format === undefined) delete axis[side]; else axis[side] = merged; return { ...spec, axis: axis.x || axis.y ? axis : undefined }; } const KIND_LABELS: Record = { bar: "Bar", line: "Line", area: "Area", donut: "Donut", kpi: "Single number", table: "Table", }; // Wave-16: ONE agg vocabulary, shared with the table kind's column headers (chartData.ts). const AGG_LABELS = CHART_AGG_LABELS; /** * ── R3 (item 18): a chart card may be a TREND over time buckets ──────────────────────────── * * Not a new chart kind — the same bar/line/area, drawn over PERIODS instead of over a * category. A chart whose number is a metric gains a Period control; setting one routes its * series through the time-series channel (`fetchTimeseries`) rather than through * `chartData`'s grouping, and clearing it puts the card back exactly where it was. * * ⚠ The 12-bucket cap in `chartData` stays CATEGORY law and is never applied here. That cap is * right for "the top 12 groups" and catastrophic for periods, where dropping buckets means * dropping MONTHS while the axis still reads like a complete run. * * Offered on bar / line / area only, and that is a judgment worth stating: a donut of months * is a part-of-whole claim about time that nobody makes, and a single-number card has no axis * to put periods on. The host narrows the same key from the other side — it keeps `bucket` * only when the chart's `y` is measure-backed (C-ACC). */ const PERIOD_LABELS: Record = { week: "Weekly", month: "Monthly", quarter: "Quarterly", year: "Yearly", }; const PERIOD_SPANS = [6, 12, 24, 36]; /** * Wave-16 C-CHARTCAP — how many buckets a card's request actually FETCHES. * * A compare card fetches `TS_YOY_BACK[bucket]` extra so every DRAWN bucket has a companion a * year back (`tsCompareChartModel` trims the picture back to the asked span). Without the * widening, a "Last 12, compared" monthly card would have nothing to compare its own first * eleven months against and the engine would refuse the whole series. */ function fetchLastN(spec: ChartSpec): number { const shown = spec.span?.lastN ?? DEFAULT_TS_LAST_N; if (spec.compare !== "prior_year") return shown; return shown + (TS_YOY_BACK[spec.bucket as TsBucket] ?? 12); } /** Charts sharing a period AND a FETCH span share ONE request; the endpoint takes many * fields. The key is the fetch span, so a compare card (which fetches wider) never * piggybacks on a plain card's narrower request. */ function periodGroupKey(spec: ChartSpec): string { return `${spec.bucket}|${fetchLastN(spec)}`; } /** * The KPI delta line's series: MONTHLY, latest bucket vs 12 back — the same-point-last-year * comparison (`pages_sales`' YTD-vs-LY-YTD law: a window slid to this month-end against the * same month-end a year earlier). A `year` bucket would compare a part-year against a full * one, which is the partial-vs-complete misread the monthly anchor avoids. * * Offered on SUM of a metric only: the time-series channel pools the metric by summing it, * so for avg/min/max the channel's number is a different statistic from the card's and the * delta would compare unlike things. */ const KPI_COMPARE_BUCKET: TsBucket = "month"; const KPI_COMPARE_FETCH = TS_YOY_BACK.month + 1; function isKpiCompareSpec(spec: ChartSpec): boolean { return ( spec.kind === "kpi" && spec.compare === "prior_year" && spec.agg === "sum" && isMetricKey(spec.y) ); } function kpiCompareGroupKey(): string { return `${KPI_COMPARE_BUCKET}|${KPI_COMPARE_FETCH}`; } function niceNumber(v: number): string { if (!Number.isFinite(v)) return "—"; const abs = Math.abs(v); if (abs >= 1e9) return (v / 1e9).toFixed(1) + "B"; if (abs >= 1e6) return (v / 1e6).toFixed(1) + "M"; if (abs >= 1e3) return (v / 1e3).toFixed(1) + "k"; return Number.isInteger(v) ? v.toLocaleString() : v.toFixed(2); } /** One card: the picture, the title, the honesty footer and (when `onChange` * is wired) the edit pane. Exported since W2-5/Y3 names it a public viz export * — the Y1 `chart` block renders one of these per spec. Behaviour is unchanged; * only the `export` keyword is new. */ export function ChartCard({ spec, data, fields, yField, onChange, onRemove, canPeriod, table, kpiCompare, }: { spec: ChartSpec; data: ChartModel; fields: Field[]; yField?: Field; onChange: (next: ChartSpec) => void; onRemove: () => void; /** * R3 — may this card offer a PERIOD? False (the default) hides the control entirely. * * Absent means the trend channel is not wired where this card is mounted — no surface scope, * so no fetch. Offering the control anyway would let a user set a period and then read an * error message about the host, which is a dead end they were walked into. Hidden rather * than disabled: a dead control invites the click that does nothing, and the wave-13 * Insights precedent this follows hid the whole tab for exactly this reason. */ canPeriod?: boolean; /** Wave-16 — the `table` kind's data, computed by the caller from the SAME rows the other * cards chart. Absent for every other kind; null while the spec cannot produce one. */ table?: GroupTable | null; /** Wave-16 — the KPI delta line. `undefined` = not asked for; `null` = asked for but not * answerable yet (loading, or the channel had no series) — either way no line renders, * because a delta placeholder under a real number reads as a broken figure. */ kpiCompare?: KpiTile | null; }) { const [editing, setEditing] = useState(false); const groupable = useMemo(() => groupableForChart(fields), [fields]); const measurable = useMemo(() => measurableForChart(fields), [fields]); // ⛔ Every field picker below renders `fieldOptions(...)`, never the offered // list raw: a stored key that has fallen out of the offer (a retyped column, a // deleted one) MUST still appear, or the control silently disowns a chart that // is working. See `fieldOptions` for what each case is called and why. const xOptions = useMemo(() => fieldOptions(groupable, fields, spec.x), [groupable, fields, spec.x]); const splitOptions = useMemo( () => fieldOptions(groupable, fields, spec.splitBy), [groupable, fields, spec.splitBy] ); const yOptions = useMemo(() => fieldOptions(measurable, fields, spec.y), [measurable, fields, spec.y]); // R3 — a trend's x axis is TIME, so the "by " clause is replaced by the period. A // card titled "Sum of Sales" that is actually monthly reads as a single total. const period = isPeriodChart(spec) ? PERIOD_LABELS[spec.bucket as TsBucket] : undefined; const xLabel = period ? "Period" : spec.x ? fields.find((f) => f.key === spec.x)?.label : undefined; const by = period ? `, ${period.toLowerCase()}` : xLabel ? ` by ${xLabel}` : ""; const auto = spec.agg === "count" ? `Count of records${by}` : `${AGG_LABELS[spec.agg]} ${yField?.label ?? "…"}${by}`; const rendererSpec = useMemo( () => toVegaLiteSpec({ spec, model: data, xTitle: spec.axis?.x?.label ?? xLabel, yTitle: spec.axis?.y?.label ?? (spec.agg === "count" ? "Records" : yField?.label), formatValue: (value) => chartValueText(value, yField, spec.axis?.y?.format, niceNumber), }), [data, spec, xLabel, yField] ); return (
{spec.title || auto}
{editing && (
{/* I16 — every chart type carries an icon. NOT a native onChange({ ...spec, kind: k })} /> {KIND_LABELS[k]} ))}
{/* Item 18's wording sweep — "Measure" was the internal word for the mechanism (wave-13 C-NAME: the wire says measure, the screen says Metric), and this row does not pick one: it picks what to DO with the numbers. */} {spec.agg !== "count" && ( )} {/* ── R3 (item 18): the period controls. Present on every card whose number is a metric; absent everywhere else, where they would promise a time axis the field does not have. */} {canPeriod && isMetricKey(spec.y) && PERIOD_KINDS.includes(spec.kind) && ( <> {!!spec.bucket && ( )} {/* ── Wave-16 C-CHARTCAP (a): the compare toggle — the door to `buildTsCompare`. The request widens by a year so every drawn bucket has its companion; the picture stays the asked span. */} {!!spec.bucket && ( )} )} {/* ── Wave-16 C-CHARTCAP (b): the KPI delta — the door to `kpiYoyFromSeries`. Sum-of-metric only: the TS channel pools a metric by summing, so any other aggregate would compare unlike statistics (the rule stated on KPI_COMPARE_BUCKET). */} {spec.kind === "kpi" && canPeriod && spec.agg === "sum" && isMetricKey(spec.y) && ( )} {/* A trend's x axis IS time, so the grouping picker would be a second, contradictory answer to the same question. Hidden rather than disabled: a dead control invites the click that does nothing. Wave-16: gated on the kind actually BEING a period chart, so a table/donut carrying a stale bucket (kind switched off a trend) does not lose the one control that gives it a grouping. */} {spec.kind !== "kpi" && !(canPeriod && spec.bucket && PERIOD_KINDS.includes(spec.kind)) && ( )} {/* ── I11 (C2): colour and stacking — the two the owner named, first. ────────── */} {spec.kind !== "kpi" && spec.kind !== "donut" && spec.kind !== "table" && !(canPeriod && spec.bucket && PERIOD_KINDS.includes(spec.kind)) && ( )} {!!spec.splitBy && (spec.kind === "bar" || spec.kind === "area") && ( )} {/* A table paints no series, so a palette row would be a control that does nothing — hidden, same rule as every other dead control here. */} {spec.kind !== "table" && ( )} {/* ── I10 (C2): axis labels and number format. ─────────────────────────────── */} {spec.kind !== "kpi" && spec.kind !== "table" && ( <> )} )} {spec.kind === "table" ? ( // ── Wave-16 C-CHARTCAP (c): the table kind — the door to `groupTable`. A real HTML // table, not a Vega mark: a table's job is exact values in rows, which an axis // grammar can only imitate. !table || table.problem ? (
{table?.problem ?? "Choose a field to group by."}
) : table.rows.length === 0 ? (
No records match this view, so there is nothing to list.
) : (
) ) : data.pending ? ( /* wave17 GRID — item 3 / R6. BEFORE `problem`, on purpose: a card still waiting has nothing to refuse yet, and showing a refusal that resolves itself a moment later is the reading error this branch exists to end. */
) : data.problem ? (
{data.problem}
) : data.points.length === 0 ? (
No records match this view, so there is nothing to chart.
) : (
{/* R6: the renderer chunk's own wait, same bare icon as every other load. */}
} > )} {/* ── Wave-16 C-CHARTCAP (b): the delta line under a KPI number. Renders ONLY when the comparison has an answer — `kpiTile`'s refusals arrive as `note` text, so "no orders yet" is said in words rather than shown as −100%. */} {spec.kind === "kpi" && kpiCompare && (
{kpiCompare.delta != null ? ( <> {kpiCompare.dir === "up" ? ( ) : kpiCompare.dir === "down" ? ( ) : ( )} {Math.abs(kpiCompare.delta).toFixed(1)}% {kpiCompare.deltaLabel} ) : ( {kpiCompare.note} )}
)} {/* The card's footnotes — every one of them is a disclosure, not decoration. */}
{data.rows.toLocaleString()} records {spec.kind === "table" && table && !table.problem ? ( // The table's own disclosures — the category model's footnotes describe a chart // this card is not drawing, so they are replaced, not appended. <> {table.shown < table.total && ( · top {table.shown.toLocaleString()} of {table.total.toLocaleString()} groups )} {table.blanks > 0 && ( · {table.blanks.toLocaleString()} with no value grouped as blank )} ) : ( <> {data.omitted > 0 && ( · top {MAX_BUCKETS} of {(data.xOrder.length + data.omitted).toLocaleString()} groups shown (+{niceNumber(data.omittedValue)} not drawn) )} {data.omittedSeries > 0 && ( · {data.omittedSeries.toLocaleString()} series not drawn )} {data.nonPositive > 0 && ( · {data.nonPositive.toLocaleString()} zero/negative group {data.nonPositive === 1 ? "" : "s"} not drawn )} {data.blanks > 0 && · {data.blanks.toLocaleString()} with no value grouped as blank} {data.missingY > 0 && ( · {data.missingY.toLocaleString()} not measurable )} )}
{/* I10 — "drag width/length". Pointer events, not a drag-and-drop payload: this resizes an element, it never transfers anything, and `setPointerCapture` keeps the gesture alive when the pointer leaves the card (a drag that dies at the card edge is the commonest resize bug). Keyboard-reachable too: arrows resize, so the feature is not mouse-only. */} ); } /** * Wave-16 C-CHARTCAP (c) — the table kind's picture: one row per group, the measure column * first, "Records" always last. Numbers format through `chartValueText`, the SAME formatter * the cells and tooltips use, so a revenue figure cannot read one way in the grid and * another in a table beside it. An unanswerable cell prints an em dash, the sheet's own * vocabulary for "no answer" — never 0, which would sum in the reader's head. */ function GroupTableView({ table, xLabel, yField, }: { table: GroupTable; xLabel: string; yField?: Field; }) { return (
{table.columns.map((c) => ( ))} {table.rows.map((r) => ( {r.values.map((v, i) => { const col = table.columns[i]; return ( ); })} ))}
{xLabel} {c.label}
{r.label} {v == null ? "—" : col.agg === "count" ? v.toLocaleString() : chartValueText(v, col.y ? yField : undefined, col.format, niceNumber)}
); } export function DashboardView({ rows, fields, fieldByKey, charts, onCharts, scope, }: { /** DISTINCT data rows from the full pipeline — the same set the grid counts. */ rows: Row[]; fields: Field[]; fieldByKey: Map; charts: ChartSpec[]; onCharts: (next: ChartSpec[]) => void; /** * Wave-14 R3 — which surface these rows came from, for the trend channel's fetch. * * ⚠ ABSENT DISABLES THE PERIOD CONTROLS, and there is no default. Asking the customer * endpoint about a cohort's pids succeeds — a cohort's customers are usually in the * reader's customer book too — and returns customer-scope numbers under a cohort-scope * card: no 403, no marker, a plausible wrong answer. That is the wave-13 Insights scar * exactly, and the answer is the same one: render nothing rather than answer a question * nobody asked. */ scope?: SurfaceScope; }) { const datas = useMemo( () => charts.map((c) => chartModel(c, rows, fieldByKey)), [charts, rows, fieldByKey] ); // Wave-16: the table kind's data, one `GroupTable` per table card, from the SAME rows. const tables = useMemo( () => charts.map((c) => (c.kind === "table" ? tableFromSpec(c, rows, fieldByKey) : null)), [charts, rows, fieldByKey] ); // ── R3: the trend channel. Charts sharing a period AND a FETCH span share ONE request, // because the endpoint takes many fields and runs one grouped query per field regardless — // twelve monthly cards on a board should cost one round trip, not twelve. Wave-16 adds two // riders: a compare card fetches `TS_YOY_BACK` wider (see fetchLastN), and a KPI compare // card joins with its fixed monthly 13-bucket request. const pids = useMemo(() => rows.map((r) => Number(r.pid)).filter(Number.isFinite), [rows]); const periodSpecs = useMemo(() => charts.filter(isPeriodChart), [charts]); const kpiCompareSpecs = useMemo(() => charts.filter(isKpiCompareSpec), [charts]); const groups = useMemo(() => { const map = new Map(); const add = (key: string, bucket: TsBucket, lastN: number, field: string | undefined) => { const entry = map.get(key) ?? { bucket, lastN, fields: [] }; if (field && !entry.fields.includes(field)) entry.fields.push(field); map.set(key, entry); }; for (const spec of periodSpecs) add(periodGroupKey(spec), spec.bucket as TsBucket, fetchLastN(spec), spec.y); for (const spec of kpiCompareSpecs) add(kpiCompareGroupKey(), KPI_COMPARE_BUCKET, KPI_COMPARE_FETCH, spec.y); return map; }, [periodSpecs, kpiCompareSpecs]); // The dependency is the GROUP SHAPE, not the array identity — a card resized or retitled // must not re-issue every trend request on the board. const groupSig = useMemo( () => JSON.stringify( [...groups.entries()].map(([k, g]) => [k, [...g.fields].sort()]) ), [groups] ); const [series, setSeries] = useState>({}); const [pending, setPending] = useState(false); const reqRef = useRef(0); useEffect(() => { if (!scope || !groups.size || !pids.length) { setSeries({}); setPending(false); return; } const seq = ++reqRef.current; setPending(true); Promise.all( [...groups.entries()].map(([key, g]) => fetchTimeseries( buildTsRequest({ pids, bucket: g.bucket, span: { lastN: g.lastN }, fields: g.fields }), scope ).then(({ payload }) => [key, payload] as const) ) ).then((pairs) => { // The board the user is waiting on is the LAST one they asked for, not the last set of // responses to arrive. if (seq !== reqRef.current) return; setSeries(Object.fromEntries(pairs)); setPending(false); }); }, [scope, groupSig, pids, groups]); /** The model for one card: the trend payload when it asked for one, else the category one. */ const modelFor = (spec: ChartSpec, fallback: ChartModel): ChartModel => { if (!isPeriodChart(spec)) return fallback; if (!scope) return { ...fallback, points: [], problem: "This chart is a trend over periods, but the host has not said which surface this view is, so it cannot be loaded.", }; const payload = series[periodGroupKey(spec)]; if (!payload) // wave17 GRID — item 3 / R6. Waiting and refusing were one string here; they are two // states now. `pending` renders the spinner, and the refusal keeps its sentence for the // case that is genuinely an answer. return pending ? { ...fallback, points: [], pending: true } : { ...fallback, points: [], problem: "The periods could not be loaded, so nothing is drawn rather than a guess.", }; const table = buildTsTable(payload); const row = table.rows.find((r) => r.field === spec.y); if (!row) return { ...fallback, points: [], problem: "The server returned no series for this metric — a composite metric has no single aggregate to bucket.", }; // ⚠ `tsRowToChartModel`, never `buildChartData`: the 12-bucket cap is category law, and // applying it here would silently delete PERIODS off a run that still looks complete. // Wave-16: a compare card's request fetched a year wider — the two-series builder trims // the picture back to the asked span so every drawn bucket has its companion. if (spec.compare === "prior_year") return tsCompareChartModel( row, table.columns, spec.bucket as TsBucket, spec.span?.lastN ?? DEFAULT_TS_LAST_N ); return tsRowToChartModel(row, table.columns); }; /** Wave-16 — the KPI card's delta, or null while the channel has not answered. */ const kpiCompareFor = (spec: ChartSpec): KpiTile | null | undefined => { if (!isKpiCompareSpec(spec)) return undefined; if (!scope) return null; const payload = series[kpiCompareGroupKey()]; if (!payload) return null; const row = buildTsTable(payload).rows.find((r) => r.field === spec.y); if (!row) return null; return kpiYoyFromSeries({ label: spec.title ?? "", values: row.cells.map((c) => c.value), back: TS_YOY_BACK[KPI_COMPARE_BUCKET], format: spec.axis?.y?.format, }); }; return (
{rows.length.toLocaleString()} records · every chart describes exactly this view's rows, so filters and cohort scope already apply
{charts.length === 0 ? (
No charts on this view yet. "+ Add chart" builds one from the records this view already matches.
) : (
{charts.map((c, i) => ( onCharts(charts.map((o) => (o.id === c.id ? next : o)))} onRemove={() => onCharts(charts.filter((o) => o.id !== c.id))} /> ))}
)}
); }