loopable / web /src /viz /DashboardView.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
dcdb685 verified
Raw
History Blame Contribute Delete
47.6 kB
// ---------------------------------------------------------------------------
// 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<ChartPalette, string> = {
brand: "Brand",
categorical: "Distinct categories",
sequential: "Low to high",
diverging: "Below / above a midpoint",
};
const FORMAT_LABELS: Record<ChartFormat, string> = {
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<ChartAxisSide>
): 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<ChartKind, string> = {
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<TsBucket, string> = {
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 <field>" 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 (
<div
className="cg-ch-card"
// I10 — the stored size. `w` is grid columns on a 12-column board, `h` is px; both
// were already clamped by cleanCharts (and again host-side), so nothing here can put
// a card outside the board.
style={{
gridColumn: `span ${spec.size?.w ?? DEFAULT_CHART_W}`,
height: spec.size?.h ? `${spec.size.h}px` : undefined,
}}
>
<div className="cg-ch-head">
<span className="cg-ch-title" title={spec.title || auto}>
{spec.title || auto}
</span>
<button
type="button"
className="cg-icon-btn cg-ch-cfg"
aria-label="Configure this chart"
aria-expanded={editing}
onClick={() => setEditing((v) => !v)}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M3 4.5h10M3 8h10M3 11.5h10"
stroke="currentColor"
strokeWidth={1.3}
strokeLinecap="round"
/>
</svg>
</button>
<button
type="button"
className="cg-icon-btn cg-ch-del"
aria-label="Remove this chart"
onClick={onRemove}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M4 4l8 8M12 4l-8 8"
stroke="currentColor"
strokeWidth={1.3}
strokeLinecap="round"
/>
</svg>
</button>
</div>
{editing && (
<div className="cg-ch-cfgbody">
{/* I16 — every chart type carries an icon. NOT a native <select>: an <option>
cannot render SVG and unicode glyphs are gate-banned (wave 8 ruled this a real
limit, not something to "solve" with a glyph), so the picker is a radio-row
list — the same shape the mode switcher uses for the same reason. */}
<div className="cg-ch-row cg-ch-kindrow" role="radiogroup" aria-label="Chart type">
<span>Chart</span>
<div className="cg-ch-kinds">
{CHART_KINDS.map((k) => (
<label
key={k}
className={"cg-ch-kind" + (spec.kind === k ? " is-on" : "")}
title={KIND_LABELS[k]}
>
<input
type="radio"
name={`cg-ch-kind-${spec.id}`}
checked={spec.kind === k}
onChange={() => onChange({ ...spec, kind: k })}
/>
<ChartKindIcon kind={k} />
<span>{KIND_LABELS[k]}</span>
</label>
))}
</div>
</div>
{/* 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. */}
<label className="cg-ch-row">
<span>Show</span>
<select
className="cg-select"
value={spec.agg}
onChange={(e) => {
const agg = e.target.value as ChartAgg;
onChange({
...spec,
agg,
// Wave-16: the KPI delta is a SUM-only comparison (the TS channel pools a
// metric by summing it), so moving the card off "sum" takes the compare
// with it — the period-belongs-to-a-metric law, one row down.
...(spec.kind === "kpi" && agg !== "sum" ? { compare: undefined } : {}),
});
}}
>
{CHART_AGGS.map((a) => (
<option key={a} value={a}>
{AGG_LABELS[a]}
</option>
))}
</select>
</label>
{spec.agg !== "count" && (
<label className="cg-ch-row">
<span>Which number</span>
<select
className="cg-select"
value={spec.y ?? ""}
onChange={(e) =>
onChange({
...spec,
y: e.target.value || undefined,
// R3 — a period belongs to a METRIC. Moving the card onto an ordinary
// number must take the period with it, or the spec keeps a key the host
// drops and the control reads set for a chart that cannot use it (the
// `stacked`-beside-`splitBy` precedent two rows down). Wave-16: the
// compare rides the same rule — both its forms need a metric.
...(isMetricKey(e.target.value)
? {}
: { bucket: undefined, span: undefined, compare: undefined }),
})
}
>
<option value="">Choose a number…</option>
{yOptions.map((o) => (
<option key={o.key} value={o.key}>
{o.label}
</option>
))}
</select>
</label>
)}
{/* ── 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) && (
<>
<label className="cg-ch-row">
<span>Over time</span>
<select
className="cg-select"
value={spec.bucket ?? ""}
onChange={(e) =>
onChange({
...spec,
bucket: e.target.value || undefined,
span: e.target.value ? spec.span : undefined,
// Wave-16: the compare SERIES is a claim about periods, so clearing
// the period clears it — a kept key would resurrect a companion line
// the moment a bucket comes back, which nobody asked for twice.
compare: e.target.value ? spec.compare : undefined,
})
}
>
{/* An explicit empty option, never a bare first one — a <select> missing
its value renders the FIRST option and silently claims a choice. */}
<option value="">No — group by a field</option>
{TS_BUCKETS.map((b) => (
<option key={b} value={b}>
{PERIOD_LABELS[b]}
</option>
))}
</select>
</label>
{!!spec.bucket && (
<label className="cg-ch-row">
<span>How far back</span>
<select
className="cg-select"
value={String(spec.span?.lastN ?? DEFAULT_TS_LAST_N)}
onChange={(e) =>
onChange({ ...spec, span: { lastN: Number(e.target.value) } })
}
>
{PERIOD_SPANS.map((n) => (
<option key={n} value={n}>
Last {n}
</option>
))}
</select>
</label>
)}
{/* ── 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 && (
<label className="cg-ch-row cg-ch-check">
<input
type="checkbox"
checked={spec.compare === "prior_year"}
onChange={(e) =>
onChange({
...spec,
compare: e.target.checked ? "prior_year" : undefined,
})
}
/>
<span>Compare to the year before</span>
</label>
)}
</>
)}
{/* ── 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) && (
<label className="cg-ch-row cg-ch-check">
<input
type="checkbox"
checked={spec.compare === "prior_year"}
onChange={(e) =>
onChange({
...spec,
compare: e.target.checked ? "prior_year" : undefined,
})
}
/>
<span>Compare to the year before</span>
</label>
)}
{/* 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)) && (
<label className="cg-ch-row">
<span>Grouped by</span>
<select
className="cg-select"
value={spec.x ?? ""}
onChange={(e) =>
onChange({
...spec,
x: e.target.value || undefined,
// ⛔ `cleanCharts` drops a `splitBy` equal to `x` ON READ, but
// nothing enforced it at the door that can CREATE the
// collision — so picking the split column as the axis left
// the split stored and filtered out of its own picker, which
// is this ticket's defect inside the control that fixes it.
// One rule, now enforced at both ends.
splitBy: e.target.value && e.target.value === spec.splitBy ? undefined : spec.splitBy,
})
}
>
<option value="">Choose a field…</option>
{xOptions.map((o) => (
<option key={o.key} value={o.key}>
{o.label}
</option>
))}
</select>
</label>
)}
<label className="cg-ch-row">
<span>Title</span>
<input
className="cg-input"
value={spec.title ?? ""}
placeholder={auto}
maxLength={60}
onChange={(e) => onChange({ ...spec, title: e.target.value || undefined })}
/>
</label>
{/* ── 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)) && (
<label className="cg-ch-row">
<span>Break down by</span>
<select
className="cg-select"
value={spec.splitBy ?? ""}
onChange={(e) =>
onChange({
...spec,
splitBy: e.target.value || undefined,
// Stacking is only a question once there ARE series. Clearing the split
// must clear the stack with it, or the spec keeps a flag the host drops
// and the checkbox reads true for a chart that cannot stack.
stacked: e.target.value ? spec.stacked : undefined,
})
}
>
{/* An explicit empty option, never a bare first one — a <select> missing its
value renders the FIRST option and silently claims a choice. */}
<option value="">Don't break it down</option>
{/* ⚠ The x column is filtered out — splitting a chart by the same
column it is grouped by draws one series per bar. A stored
splitBy survives that filter only because BOTH doors now keep
`splitBy !== x`: `cleanCharts` on read, and the x picker's own
onChange above. Neither alone is enough. */}
{splitOptions
.filter((o) => o.key !== spec.x)
.map((o) => (
<option key={o.key} value={o.key}>
{o.label}
</option>
))}
</select>
</label>
)}
{!!spec.splitBy && (spec.kind === "bar" || spec.kind === "area") && (
<label className="cg-ch-row cg-ch-check">
<input
type="checkbox"
checked={spec.stacked === true}
onChange={(e) => onChange({ ...spec, stacked: e.target.checked || undefined })}
/>
<span>Stack them on top of each other</span>
</label>
)}
{/* 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" && (
<label className="cg-ch-row">
<span>Colours</span>
<select
className="cg-select"
value={spec.palette ?? ""}
onChange={(e) =>
onChange({ ...spec, palette: (e.target.value || undefined) as ChartPalette })
}
>
<option value="">Default</option>
{CHART_PALETTES.map((pk) => (
<option key={pk} value={pk}>
{PALETTE_LABELS[pk]}
</option>
))}
</select>
</label>
)}
{/* ── I10 (C2): axis labels and number format. ─────────────────────────────── */}
{spec.kind !== "kpi" && spec.kind !== "table" && (
<>
<label className="cg-ch-row">
<span>Bottom axis label</span>
<input
className="cg-input"
value={spec.axis?.x?.label ?? ""}
placeholder={xLabel ?? "Category"}
maxLength={MAX_AXIS_LABEL}
onChange={(e) => onChange(withAxis(spec, "x", { label: e.target.value }))}
/>
</label>
<label className="cg-ch-row">
<span>Side axis label</span>
<input
className="cg-input"
value={spec.axis?.y?.label ?? ""}
placeholder={yField?.label ?? "Value"}
maxLength={MAX_AXIS_LABEL}
onChange={(e) => onChange(withAxis(spec, "y", { label: e.target.value }))}
/>
</label>
</>
)}
<label className="cg-ch-row">
<span>Number format</span>
<select
className="cg-select"
value={spec.axis?.y?.format ?? "auto"}
onChange={(e) =>
onChange(withAxis(spec, "y", { format: e.target.value as ChartFormat }))
}
>
{CHART_FORMATS.map((f) => (
<option key={f} value={f}>
{FORMAT_LABELS[f]}
</option>
))}
</select>
</label>
</div>
)}
{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 ? (
<div className="cg-ch-empty">{table?.problem ?? "Choose a field to group by."}</div>
) : table.rows.length === 0 ? (
<div className="cg-ch-empty">
No records match this view, so there is nothing to list.
</div>
) : (
<div className="cg-ch-viz cg-ch-viz--table">
<GroupTableView table={table} xLabel={xLabel ?? "Group"} yField={yField} />
</div>
)
) : 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. */
<div className="cg-ch-empty">
<span className="lp-spin" role="status" aria-label="Loading" />
</div>
) : data.problem ? (
<div className="cg-ch-empty">{data.problem}</div>
) : data.points.length === 0 ? (
<div className="cg-ch-empty">No records match this view, so there is nothing to chart.</div>
) : (
<div className={`cg-ch-viz cg-ch-viz--${spec.kind}`}>
{/* R6: the renderer chunk's own wait, same bare icon as every other load. */}
<Suspense
fallback={
<div className="cg-ch-loading">
<span className="lp-spin" role="status" aria-label="Loading" />
</div>
}
>
<VegaLiteChart spec={rendererSpec} label={spec.title || auto} />
</Suspense>
</div>
)}
{/* ── 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 as100%. */}
{spec.kind === "kpi" && kpiCompare && (
<div
className={`cg-ch-kpidelta is-${kpiCompare.dir}`}
title={
kpiCompare.prior != null
? `This period ${chartValueText(kpiCompare.value, yField, spec.axis?.y?.format, niceNumber)} · a year earlier ${chartValueText(kpiCompare.prior, yField, spec.axis?.y?.format, niceNumber)} (month-end anchored)`
: undefined
}
>
{kpiCompare.delta != null ? (
<>
<svg width="10" height="10" viewBox="0 0 14 14" aria-hidden>
{kpiCompare.dir === "up" ? (
<path d="M3 9.5l4-5 4 5z" fill="currentColor" />
) : kpiCompare.dir === "down" ? (
<path d="M3 4.5l4 5 4-5z" fill="currentColor" />
) : (
<path d="M3 7h8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
)}
</svg>
<span>
{Math.abs(kpiCompare.delta).toFixed(1)}% {kpiCompare.deltaLabel}
</span>
</>
) : (
<span>{kpiCompare.note}</span>
)}
</div>
)}
{/* The card's footnotes — every one of them is a disclosure, not decoration. */}
<div className="cg-ch-foot">
<span>{data.rows.toLocaleString()} records</span>
{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 && (
<span
title={`${(table.total - table.shown).toLocaleString()} more groups are not listed. The table is sorted by its first column, largest first.`}
>
· top {table.shown.toLocaleString()} of {table.total.toLocaleString()} groups
</span>
)}
{table.blanks > 0 && (
<span>· {table.blanks.toLocaleString()} with no value grouped as blank</span>
)}
</>
) : (
<>
{data.omitted > 0 && (
<span title={`The ${data.omitted.toLocaleString()} smallest groups are not drawn. Together they account for ${niceNumber(data.omittedValue)}.`}>
· top {MAX_BUCKETS} of {(data.xOrder.length + data.omitted).toLocaleString()} groups
shown (+{niceNumber(data.omittedValue)} not drawn)
</span>
)}
{data.omittedSeries > 0 && (
<span
title={`${data.omittedSeries.toLocaleString()} smaller series are not drawn. Together they account for ${niceNumber(data.omittedSeriesValue)}.`}
>
· {data.omittedSeries.toLocaleString()} series not drawn
</span>
)}
{data.nonPositive > 0 && (
<span
title={`Donut slices require positive values. These groups have a combined value of ${niceNumber(data.nonPositiveValue)} and are not included in the circle.`}
>
· {data.nonPositive.toLocaleString()} zero/negative group
{data.nonPositive === 1 ? "" : "s"} not drawn
</span>
)}
{data.blanks > 0 && <span>· {data.blanks.toLocaleString()} with no value grouped as blank</span>}
{data.missingY > 0 && (
<span title="These records carry no number for the measured field, so they are counted in the group but excluded from the aggregate.">
· {data.missingY.toLocaleString()} not measurable
</span>
)}
</>
)}
</div>
{/*
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.
*/}
<button
type="button"
className="cg-ch-resize"
aria-label={`Resize ${spec.title || auto}`}
title="Drag to resize · arrow keys also work"
onPointerDown={(e) => {
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
const card = e.currentTarget.parentElement as HTMLElement | null;
const board = card?.parentElement as HTMLElement | null;
if (!card || !board) return;
const startX = e.clientX;
const startY = e.clientY;
const startW = spec.size?.w ?? DEFAULT_CHART_W;
const startH = spec.size?.h ?? card.getBoundingClientRect().height;
// One board column, measured rather than assumed — the board is responsive, so a
// hard-coded column width would make the drag lag the cursor at other widths.
const colPx = Math.max(1, board.getBoundingClientRect().width / CHART_BOARD_COLS);
const move = (ev: PointerEvent) => {
const w = clampRange(
Math.round(startW + (ev.clientX - startX) / colPx),
CHART_W_RANGE
);
const h = clampRange(Math.round(startH + (ev.clientY - startY)), CHART_H_RANGE);
onChange({ ...spec, size: { w, h } });
};
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
}}
onKeyDown={(e) => {
const dw = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
const dh = e.key === "ArrowDown" ? 20 : e.key === "ArrowUp" ? -20 : 0;
if (!dw && !dh) return;
e.preventDefault();
onChange({
...spec,
size: {
w: clampRange((spec.size?.w ?? DEFAULT_CHART_W) + dw, CHART_W_RANGE),
h: clampRange((spec.size?.h ?? 260) + dh, CHART_H_RANGE),
},
});
}}
>
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
<path
d="M9 3.5L3.5 9M9 7L7 9"
fill="none"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
/>
</svg>
</button>
</div>
);
}
/**
* 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 (
<div className="cg-ch-table">
<table>
<thead>
<tr>
<th>{xLabel}</th>
{table.columns.map((c) => (
<th key={c.key} className="cg-ch-td-num">
{c.label}
</th>
))}
</tr>
</thead>
<tbody>
{table.rows.map((r) => (
<tr key={r.key || "(blank)"}>
<td title={r.label}>{r.label}</td>
{r.values.map((v, i) => {
const col = table.columns[i];
return (
<td key={col.key} className="cg-ch-td-num">
{v == null
? "—"
: col.agg === "count"
? v.toLocaleString()
: chartValueText(v, col.y ? yField : undefined, col.format, niceNumber)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
}
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<string, Field>;
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<string, { bucket: TsBucket; lastN: number; fields: string[] }>();
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<Record<string, TsPayload | null>>({});
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 (
<div className="cg-dashview">
<div className="cg-dash-bar">
<span className="cg-cal-note">
{rows.length.toLocaleString()} records · every chart describes exactly this view's
rows, so filters and cohort scope already apply
</span>
<button
type="button"
className="cg-btn cg-btn--primary cg-dash-add"
disabled={charts.length >= MAX_CHARTS}
title={
charts.length >= MAX_CHARTS
? `A view holds at most ${MAX_CHARTS} charts.`
: undefined
}
onClick={() => {
const id = `ch_${Math.random().toString(36).slice(2, 10)}`;
onCharts([...charts, defaultChart(id, fields)]);
}}
>
+ Add chart
</button>
</div>
{charts.length === 0 ? (
<div className="cg-mode-empty">
No charts on this view yet. "+ Add chart" builds one from the records this view
already matches.
</div>
) : (
<div className="cg-dash-grid">
{charts.map((c, i) => (
<ChartCard
key={c.id}
spec={c}
data={modelFor(c, datas[i])}
fields={fields}
yField={c.y ? fieldByKey.get(c.y) : undefined}
canPeriod={!!scope}
table={tables[i]}
kpiCompare={kpiCompareFor(c)}
onChange={(next) => onCharts(charts.map((o) => (o.id === c.id ? next : o)))}
onRemove={() => onCharts(charts.filter((o) => o.id !== c.id))}
/>
))}
</div>
)}
</div>
);
}