File size: 47,608 Bytes
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dcdb685
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dcdb685
 
 
 
 
 
 
 
 
 
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dcdb685
 
 
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dcdb685
 
 
 
 
 
 
 
 
 
 
 
 
bf8519f
 
dcdb685
 
 
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dcdb685
 
 
 
 
 
 
 
 
 
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
// ---------------------------------------------------------------------------
// 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 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>
  );
}