| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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"; |
| |
| |
| |
| 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"; |
| |
| |
| |
| import { ChartKindIcon } from "../customer-grid/icons"; |
| |
| |
| |
| |
| 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")); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| 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)", |
| }; |
|
|
| |
| |
| |
| |
| |
| 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", |
| }; |
|
|
| |
| const AGG_LABELS = CHART_AGG_LABELS; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const PERIOD_LABELS: Record<TsBucket, string> = { |
| week: "Weekly", |
| month: "Monthly", |
| quarter: "Quarterly", |
| year: "Yearly", |
| }; |
|
|
| const PERIOD_SPANS = [6, 12, 24, 36]; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| 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); |
| } |
|
|
| |
| |
| |
| function periodGroupKey(spec: ChartSpec): string { |
| return `${spec.bucket}|${fetchLastN(spec)}`; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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); |
| } |
|
|
| |
| |
| |
| |
| 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 as −100%. */} |
| {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> |
| ); |
| } |
| |