chordia-ui 3.9.0 → 3.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/DataTable2.cjs.js +1 -1
  2. package/dist/DataTable2.cjs.js.map +1 -1
  3. package/dist/DataTable2.es.js +611 -604
  4. package/dist/DataTable2.es.js.map +1 -1
  5. package/dist/PerformancePanel.cjs.js +1 -1
  6. package/dist/PerformancePanel.cjs.js.map +1 -1
  7. package/dist/PerformancePanel.es.js +1068 -816
  8. package/dist/PerformancePanel.es.js.map +1 -1
  9. package/dist/SupervisorSelect.cjs.js +2 -0
  10. package/dist/SupervisorSelect.cjs.js.map +1 -0
  11. package/dist/SupervisorSelect.es.js +352 -0
  12. package/dist/SupervisorSelect.es.js.map +1 -0
  13. package/dist/components/Signals.cjs.js +1 -1
  14. package/dist/components/Signals.cjs.js.map +1 -1
  15. package/dist/components/Signals.es.js +273 -318
  16. package/dist/components/Signals.es.js.map +1 -1
  17. package/dist/components/UpdatedInteractionDetails.cjs.js +3 -3
  18. package/dist/components/UpdatedInteractionDetails.cjs.js.map +1 -1
  19. package/dist/components/UpdatedInteractionDetails.es.js +98 -92
  20. package/dist/components/UpdatedInteractionDetails.es.js.map +1 -1
  21. package/dist/components/performance.cjs.js +1 -1
  22. package/dist/components/performance.cjs.js.map +1 -1
  23. package/dist/components/performance.es.js +116 -349
  24. package/dist/components/performance.es.js.map +1 -1
  25. package/dist/components/reports.cjs.js +2 -2
  26. package/dist/components/reports.cjs.js.map +1 -1
  27. package/dist/components/reports.es.js +122 -141
  28. package/dist/components/reports.es.js.map +1 -1
  29. package/dist/index.cjs.js +1 -1
  30. package/dist/index.es.js +1 -1
  31. package/dist/pages/interactionDetails.cjs.js +2 -2
  32. package/dist/pages/interactionDetails.cjs.js.map +1 -1
  33. package/dist/pages/interactionDetails.es.js +130 -124
  34. package/dist/pages/interactionDetails.es.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/components/Signals/SignalDetailsPage.jsx +2 -29
  37. package/src/components/Signals/SignalsListPage.jsx +2 -24
  38. package/src/components/UpdatedInteractionDetails/UpdatedCoachingSynthesisCard.jsx +8 -1
  39. package/src/components/data/DataTable2.jsx +19 -5
  40. package/src/components/pages/interactionDetails/CoachingSynthesisCard.jsx +8 -1
  41. package/src/components/performance/PerformanceDetailsPage.jsx +230 -75
  42. package/src/components/performance/PerformancePanel.jsx +42 -45
  43. package/src/components/performance/index.js +27 -0
  44. package/src/components/performance/performanceApiMap.js +133 -0
  45. package/src/components/performance/performanceMetrics.js +63 -0
  46. package/src/components/performance/performanceRangeFormat.js +92 -0
  47. package/src/components/performance/performanceSparkline.js +52 -0
  48. package/src/components/reports/ReportsList.jsx +2 -25
@@ -12,6 +12,7 @@ import {
12
12
  ChevronLeft,
13
13
  ChevronRight,
14
14
  TrendingUp,
15
+ TrendingDown,
15
16
  Lightbulb,
16
17
  ShieldCheck,
17
18
  FolderKanban,
@@ -19,6 +20,25 @@ import {
19
20
  } from "lucide-react";
20
21
  import DataTable2 from "../data/DataTable2";
21
22
  import { DateRangeButton } from "./PerformancePanel";
23
+ import {
24
+ formatEightHourRangeEndpoints,
25
+ formatLocalDate,
26
+ getPresetWindowDates,
27
+ } from "./performanceRangeFormat";
28
+ import {
29
+ SPARK_DEFAULT_AREA,
30
+ SPARK_DEFAULT_LINE,
31
+ buildSparkPaths,
32
+ normalizeSparkPoints,
33
+ } from "./performanceSparkline";
34
+ import {
35
+ METRIC_PLACEHOLDER,
36
+ formatMetricDisplay,
37
+ hasChartData,
38
+ hasMeaningfulMetric,
39
+ resolveKpiTrend,
40
+ resolveTrend,
41
+ } from "./performanceMetrics";
22
42
 
23
43
  // Color values resolve to CSS variables declared in `src/tokens/colors.css`.
24
44
  // Anything new added here MUST first be added to that file as a `--*` token.
@@ -48,6 +68,24 @@ const C = {
48
68
  const FONT_DISPLAY = "var(--font-sans, 'Averta', ui-sans-serif, system-ui, sans-serif)";
49
69
  const FONT_BODY = "var(--font-sans, 'Averta', ui-sans-serif, system-ui, sans-serif)";
50
70
 
71
+ function TrendArrow({ direction = "up", color = C.muted }) {
72
+ const upPath =
73
+ "M12.4167 0.75H17.4167M17.4167 0.75V5.75M17.4167 0.75L10.3333 7.83333L6.16667 3.66667L0.75 9.08333";
74
+ const downPath =
75
+ "M12.4167 9.25H17.4167M17.4167 9.25V4.25M17.4167 9.25L10.3333 2.16667L6.16667 6.33333L0.75 0.91667";
76
+ return (
77
+ <svg width={18} height={9.5} viewBox="0 0 19 10" fill="none" style={{ flexShrink: 0 }}>
78
+ <path
79
+ d={direction === "up" ? upPath : downPath}
80
+ stroke={color}
81
+ strokeWidth="1.5"
82
+ strokeLinecap="round"
83
+ strokeLinejoin="round"
84
+ />
85
+ </svg>
86
+ );
87
+ }
88
+
51
89
  // MM/DD/YYYY in the viewer's local timezone — matches PerformancePanel's
52
90
  // `formatLocalDate`. Accepts Date instances or strings (e.g. "2026-03-16");
53
91
  // returns the input untouched if it can't be parsed.
@@ -68,42 +106,26 @@ function formatDisplayDate(input) {
68
106
  return str;
69
107
  }
70
108
 
71
- // Canned curve used when the caller doesn't supply real points. Kept around
72
- // so existing demos / fixtures don't visually regress.
73
- const SPARK_DEFAULT_LINE =
74
- "M1.00049 13.5213C7.85975 11.7032 8.88149 4.73912 13.7838 4.73912C19.8344 4.73912 23.6237 17.166 29.1281 17.166C34.5998 17.166 37.4149 4.88121 43.4004 8.61051C47.9764 11.4616 50.6186 1.00024 50.6186 1.00024";
75
- const SPARK_DEFAULT_AREA =
76
- "M1.00049 16.692C8.88149 15.6975 8.88149 7.79403 13.7838 7.79403C19.8344 7.79403 23.6237 21.4034 29.1281 21.4034C34.5998 21.4034 37.4149 8.93821 43.4004 12.7223C47.9764 15.6152 50.6186 5.00024 50.6186 5.00024V36.0002C50.6186 36.0002 10.8223 36.0002 1.00049 36.0002C1.00049 28.8653 1.00049 19.4871 1.00049 16.692Z";
77
-
78
- // Project a numeric series onto an SVG viewBox so we can draw a real line +
79
- // filled area. Returns `null` when there aren't enough usable values (so the
80
- // caller can fall back to the canned curve).
81
- function buildSparkPaths(points, vbW = 52, vbH = 36) {
82
- if (!Array.isArray(points)) return null;
83
- const numeric = points
84
- .map((p) => Number(p))
85
- .filter((n) => Number.isFinite(n));
86
- if (numeric.length < 2) return null;
87
- const min = Math.min(...numeric);
88
- const max = Math.max(...numeric);
89
- // Inset so the stroke doesn't clip and a flat line still draws horizontally.
90
- const padY = 4;
91
- const span = max - min || 1;
92
- const usableH = vbH - padY * 2 - 4; // leave a little headroom at bottom too
93
- const sx = (i) => (numeric.length === 1 ? 0 : (i / (numeric.length - 1)) * (vbW - 2)) + 1;
94
- const sy = (v) => padY + (1 - (v - min) / span) * usableH;
95
- let line = "";
96
- for (let i = 0; i < numeric.length; i++) {
97
- line += `${i === 0 ? "M" : "L"}${sx(i).toFixed(2)},${sy(numeric[i]).toFixed(2)}`;
98
- }
99
- const lastX = sx(numeric.length - 1);
100
- const firstX = sx(0);
101
- const area = `${line} L${lastX.toFixed(2)},${vbH} L${firstX.toFixed(2)},${vbH} Z`;
102
- return { line, area };
103
- }
104
-
105
- function Sparkline({ trend = "up", points, width = 49.618, height = 35 }) {
109
+ function Sparkline({ trend = "up", points, empty = false, width = 49.618, height = 35 }) {
106
110
  const id = React.useId();
111
+ if (empty) {
112
+ return (
113
+ <svg
114
+ width={width}
115
+ height={height}
116
+ viewBox="0 0 52 36"
117
+ fill="none"
118
+ style={{ flexShrink: 0 }}
119
+ >
120
+ <path
121
+ d="M2,18 L50,18"
122
+ stroke={C.ink}
123
+ strokeWidth="2"
124
+ strokeLinecap="round"
125
+ />
126
+ </svg>
127
+ );
128
+ }
107
129
  const built = buildSparkPaths(points);
108
130
  const linePath = built ? built.line : SPARK_DEFAULT_LINE;
109
131
  const areaPath = built ? built.area : SPARK_DEFAULT_AREA;
@@ -156,17 +178,31 @@ function Sparkline({ trend = "up", points, width = 49.618, height = 35 }) {
156
178
  }
157
179
 
158
180
  function KpiCell({ value, label, sub, trend = "up", points }) {
181
+ const displayValue = formatMetricDisplay(value);
182
+ const hasData = hasMeaningfulMetric(value) || normalizeSparkPoints(points) != null;
183
+ const direction = resolveKpiTrend(trend, value, points);
159
184
  return (
160
185
  <div
161
186
  style={{
162
- flex: 1,
163
187
  display: "flex",
164
188
  alignItems: "center",
189
+ justifyContent: "center",
165
190
  gap: 16,
166
191
  minWidth: 0,
167
192
  }}
168
193
  >
169
- <Sparkline trend={trend} points={points} />
194
+ <div
195
+ style={{
196
+ display: "flex",
197
+ flexDirection: "column",
198
+ gap: 4,
199
+ flexShrink: 0,
200
+ alignItems: "flex-end",
201
+ }}
202
+ >
203
+ <Sparkline trend={direction} points={points} empty={!hasData} />
204
+ {hasData ? <TrendArrow direction={direction} /> : null}
205
+ </div>
170
206
  <div style={{ display: "flex", flexDirection: "column", gap: 6, minWidth: 0 }}>
171
207
  <div
172
208
  style={{
@@ -179,7 +215,7 @@ function KpiCell({ value, label, sub, trend = "up", points }) {
179
215
  whiteSpace: "nowrap",
180
216
  }}
181
217
  >
182
- {value}
218
+ {displayValue}
183
219
  </div>
184
220
  <div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
185
221
  <span
@@ -279,6 +315,7 @@ function CompassLineChart({
279
315
  // index of the marker to render filled (defaults to the last point)
280
316
  highlightIndex,
281
317
  }) {
318
+ const [hoveredPoint, setHoveredPoint] = React.useState(null);
282
319
  const width = 460;
283
320
  const height = 110;
284
321
  const hasData = Array.isArray(points) && points.length > 0;
@@ -360,7 +397,17 @@ function CompassLineChart({
360
397
  const minX = normalized[0][0];
361
398
  const maxX = normalized[normalized.length - 1][0];
362
399
  const xSpan = maxX - minX || 1;
363
- const sx = (x) => ((x - minX) / xSpan) * width;
400
+ // Keep endpoints away from the SVG edge so first/last point markers don't clip.
401
+ const xPad = 8;
402
+ const innerWidth = Math.max(1, width - xPad * 2);
403
+ const sx = (x) => xPad + ((x - minX) / xSpan) * innerWidth;
404
+ const formatPointValue = (value) => {
405
+ const n = Number(value);
406
+ if (!Number.isFinite(n)) return String(value ?? "");
407
+ const abs = Math.abs(n);
408
+ const decimals = abs >= 100 ? 0 : abs >= 10 ? 1 : 2;
409
+ return n.toFixed(decimals);
410
+ };
364
411
  const linePath = normalized
365
412
  .map(([x, y], i) => `${i === 0 ? "M" : "L"}${sx(x)},${y}`)
366
413
  .join(" ");
@@ -385,11 +432,50 @@ function CompassLineChart({
385
432
  ))}
386
433
  </div>
387
434
  <div style={{ flex: 1, position: "relative", minWidth: 0 }}>
435
+ {hoveredPoint && (
436
+ <div
437
+ style={{
438
+ position: "absolute",
439
+ left: `${hoveredPoint.xPct}%`,
440
+ top: `${hoveredPoint.yPct}%`,
441
+ transform: "translate(-50%, calc(-100% - 10px))",
442
+ pointerEvents: "none",
443
+ zIndex: 2,
444
+ background: C.black,
445
+ color: C.white,
446
+ borderRadius: 6,
447
+ minWidth: 60,
448
+ padding: "8px 10px",
449
+ boxShadow: "0 6px 16px rgba(0,0,0,0.18)",
450
+ fontFamily: FONT_BODY,
451
+ fontSize: 11,
452
+ lineHeight: 1.25,
453
+ whiteSpace: "nowrap",
454
+ textAlign: "left",
455
+ }}
456
+ >
457
+ <div>{hoveredPoint.label}</div>
458
+ <div style={{ fontWeight: 600 }}>{hoveredPoint.value}</div>
459
+ <div
460
+ style={{
461
+ position: "absolute",
462
+ left: "50%",
463
+ bottom: -5,
464
+ width: 10,
465
+ height: 10,
466
+ background: C.black,
467
+ transform: "translateX(-50%) rotate(45deg)",
468
+ borderRadius: 2,
469
+ }}
470
+ />
471
+ </div>
472
+ )}
388
473
  <svg
389
474
  width="100%"
390
475
  height={height + 14}
391
476
  viewBox={`0 0 ${width} ${height + 14}`}
392
477
  preserveAspectRatio="none"
478
+ onMouseLeave={() => setHoveredPoint(null)}
393
479
  >
394
480
  <defs>
395
481
  <linearGradient id={`area-${gradId}`} x1="0" y1="0" x2="0" y2="1">
@@ -432,6 +518,14 @@ function CompassLineChart({
432
518
  stroke={C.black}
433
519
  strokeWidth="1.5"
434
520
  vectorEffect="non-scaling-stroke"
521
+ onMouseEnter={() => {
522
+ setHoveredPoint({
523
+ xPct: (sx(x) / width) * 100,
524
+ yPct: (y / (height + 28)) * 100,
525
+ label: allXLabels[i] || `Point ${i + 1}`,
526
+ value: formatPointValue(dataPairs[i]?.[1]),
527
+ });
528
+ }}
435
529
  />
436
530
  );
437
531
  })}
@@ -444,7 +538,8 @@ function CompassLineChart({
444
538
  fontFamily: FONT_BODY,
445
539
  fontSize: 10,
446
540
  color: C.neutral800,
447
- marginTop: 6,
541
+ marginTop: 8,
542
+ marginBottom: 4,
448
543
  overflow: "hidden",
449
544
  }}
450
545
  >
@@ -458,6 +553,10 @@ function CompassLineChart({
458
553
  }
459
554
 
460
555
  function ChartCard({ title, value, delta, children }) {
556
+ const displayValue = formatMetricDisplay(value);
557
+ const showDelta = hasMeaningfulMetric(delta);
558
+ // Value line uses em-dash fallback; delta/trend icons stay hidden for placeholders.
559
+ const deltaTrend = showDelta ? resolveTrend("up", delta) : "up";
461
560
  return (
462
561
  <div
463
562
  style={{
@@ -494,8 +593,22 @@ function ChartCard({ title, value, delta, children }) {
494
593
  fontWeight: 600,
495
594
  }}
496
595
  >
497
- <span style={{ fontSize: 20, color: C.black, lineHeight: 1 }}>{value}</span>
498
- <span style={{ fontSize: 10, color: C.neutral300, lineHeight: 1 }}>{delta}</span>
596
+ <span style={{ fontSize: 20, color: C.black, lineHeight: 1 }}>{displayValue}</span>
597
+ {showDelta ? (
598
+ <span
599
+ style={{
600
+ display: "inline-flex",
601
+ alignItems: "center",
602
+ gap: 3,
603
+ fontSize: 10,
604
+ color: C.neutral300,
605
+ lineHeight: 1,
606
+ }}
607
+ >
608
+ <TrendArrow direction={deltaTrend} color={C.neutral300} />
609
+ {delta}
610
+ </span>
611
+ ) : null}
499
612
  </span>
500
613
  </div>
501
614
  <div style={{ flex: 1 }}>{children}</div>
@@ -611,7 +724,11 @@ function AccordionRow({
611
724
  defaultExpanded = false,
612
725
  }) {
613
726
  const [expanded, setExpanded] = React.useState(defaultExpanded);
614
- const trailing = percent ?? delta ?? "";
727
+ const trailingRaw = percent ?? delta;
728
+ const showTrailing = hasMeaningfulMetric(trailingRaw);
729
+ const trailing = showTrailing ? String(trailingRaw) : METRIC_PLACEHOLDER;
730
+ const trailingTrend = showTrailing ? resolveTrend("up", trailing) : "up";
731
+ const TrendIcon = trailingTrend === "down" ? TrendingDown : TrendingUp;
615
732
  const rows = Array.isArray(bullets) ? bullets : [];
616
733
  // Outer container is a div so the bullet rows inside (which may hold
617
734
  // <a> links to the interaction detail page) aren't nested inside an
@@ -655,11 +772,21 @@ function AccordionRow({
655
772
  <div style={{ display: "flex", gap: 16 }}>
656
773
  <div style={{ display: "flex", alignItems: "center", gap: 4 }}>
657
774
  <Phone size={12} color={C.ink} strokeWidth={1.75} />
658
- <span style={{ fontFamily: FONT_BODY, fontSize: 12, color: C.ink }}>{calls}</span>
775
+ <span style={{ fontFamily: FONT_BODY, fontSize: 12, color: C.ink }}>{calls} calls</span>
659
776
  </div>
660
777
  <div style={{ display: "flex", alignItems: "center", gap: 4 }}>
661
- <TrendingUp size={12} color={C.ink} strokeWidth={1.75} />
662
- <span style={{ fontFamily: FONT_BODY, fontSize: 12, color: C.ink }}>{trailing}</span>
778
+ {showTrailing ? (
779
+ <TrendIcon size={12} color={C.ink} strokeWidth={1.75} />
780
+ ) : null}
781
+ <span
782
+ style={{
783
+ fontFamily: FONT_BODY,
784
+ fontSize: 12,
785
+ color: showTrailing ? C.ink : C.muted,
786
+ }}
787
+ >
788
+ {trailing}
789
+ </span>
663
790
  </div>
664
791
  </div>
665
792
  </div>
@@ -897,13 +1024,28 @@ export default function PerformanceDetailsPage({
897
1024
  onSessionsVisibleColumnsChange,
898
1025
  }) {
899
1026
  const customRangeActive = !!(dateRange?.from && dateRange?.to);
1027
+ // 8h label uses a rolling window (now − 8h), same as the list panel. Hosts
1028
+ // often pass date-only startDate/endDate (midnight), which would render as
1029
+ // "12:00 AM – 12:00 AM"; derive the range locally instead.
1030
+ const eightHourRange = (() => {
1031
+ if (customRangeActive || selectedWindow !== "8h") return null;
1032
+ const range = getPresetWindowDates("8h");
1033
+ return range ? formatEightHourRangeEndpoints(range.from, range.to) : null;
1034
+ })();
1035
+ const presetDateRange = (() => {
1036
+ if (customRangeActive || selectedWindow === "8h") return null;
1037
+ const range = getPresetWindowDates(selectedWindow);
1038
+ if (!range) return null;
1039
+ return {
1040
+ from: formatLocalDate(range.from),
1041
+ to: formatLocalDate(range.to),
1042
+ };
1043
+ })();
900
1044
 
901
1045
  // A chart card is only worth rendering when the host has something concrete
902
1046
  // for it — either a headline value, a delta, or actual chart points. Without
903
1047
  // that we'd ship empty frames with "No data" inside, which leaks library
904
1048
  // demo copy. Hosts populate progressively; gallery passes everything.
905
- const hasChartData = (c) =>
906
- !!(c && (c.value || c.delta || (Array.isArray(c.points) && c.points.length > 0)));
907
1049
  const showRow1 =
908
1050
  hasChartData(baselineChart) || hasChartData(agentLiftChart) || hasChartData(scoreChart);
909
1051
  const showRow2 = hasChartData(csatChart) || hasChartData(volumeChart);
@@ -1032,7 +1174,9 @@ export default function PerformanceDetailsPage({
1032
1174
  lineHeight: 1.5,
1033
1175
  }}
1034
1176
  >
1035
- {formatDisplayDate(startDate)}
1177
+ {eightHourRange?.from ??
1178
+ presetDateRange?.from ??
1179
+ formatDisplayDate(startDate)}
1036
1180
  </span>
1037
1181
  <ArrowRight size={12} color={C.ink} strokeWidth={1.75} style={{ flexShrink: 0 }} />
1038
1182
  <span
@@ -1043,7 +1187,9 @@ export default function PerformanceDetailsPage({
1043
1187
  lineHeight: 1.5,
1044
1188
  }}
1045
1189
  >
1046
- {formatDisplayDate(endDate)}
1190
+ {eightHourRange?.to ??
1191
+ presetDateRange?.to ??
1192
+ formatDisplayDate(endDate)}
1047
1193
  </span>
1048
1194
  </div>
1049
1195
  <div style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
@@ -1085,33 +1231,42 @@ export default function PerformanceDetailsPage({
1085
1231
  borderRadius: 8,
1086
1232
  padding: 16,
1087
1233
  display: "flex",
1088
- gap: 24,
1089
1234
  alignItems: "center",
1090
1235
  }}
1091
1236
  >
1092
- <KpiCell
1093
- value={kpiBaseline.value}
1094
- label={kpiBaseline.label}
1095
- sub={kpiBaseline.sub}
1096
- trend={kpiBaseline.trend}
1097
- points={kpiBaseline.points}
1098
- />
1099
- <ArrowRight size={20} color={C.ink} strokeWidth={1.75} style={{ flexShrink: 0 }} />
1100
- <KpiCell
1101
- value={kpiAgentLift.value}
1102
- label={kpiAgentLift.label}
1103
- sub={kpiAgentLift.sub}
1104
- trend={kpiAgentLift.trend}
1105
- points={kpiAgentLift.points}
1106
- />
1107
- <ArrowRight size={20} color={C.ink} strokeWidth={1.75} style={{ flexShrink: 0 }} />
1108
- <KpiCell
1109
- value={kpiScore.value}
1110
- label={kpiScore.label}
1111
- sub={kpiScore.sub}
1112
- trend={kpiScore.trend}
1113
- points={kpiScore.points}
1114
- />
1237
+ {[
1238
+ { key: "baseline", kpi: kpiBaseline },
1239
+ { key: "agent-lift", kpi: kpiAgentLift },
1240
+ { key: "score", kpi: kpiScore },
1241
+ ].map((col, i) => (
1242
+ <React.Fragment key={col.key}>
1243
+ {i > 0 ? (
1244
+ <ArrowRight
1245
+ size={20}
1246
+ color={C.ink}
1247
+ strokeWidth={1.75}
1248
+ style={{ flexShrink: 0, margin: "0 24px" }}
1249
+ />
1250
+ ) : null}
1251
+ <div
1252
+ style={{
1253
+ flex: 1,
1254
+ display: "flex",
1255
+ justifyContent: "center",
1256
+ alignItems: "center",
1257
+ minWidth: 0,
1258
+ }}
1259
+ >
1260
+ <KpiCell
1261
+ value={col.kpi.value}
1262
+ label={col.kpi.label}
1263
+ sub={col.kpi.sub}
1264
+ trend={col.kpi.trend}
1265
+ points={col.kpi.points}
1266
+ />
1267
+ </div>
1268
+ </React.Fragment>
1269
+ ))}
1115
1270
  </div>
1116
1271
 
1117
1272
  {showRow1 && (
@@ -1179,7 +1334,7 @@ export default function PerformanceDetailsPage({
1179
1334
  </div>
1180
1335
  </div>
1181
1336
  <div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 16, minWidth: 0 }}>
1182
- <SectionHeading icon={Lightbulb} title={guidanceTitle} iconColor={C.accentYellow} />
1337
+ <SectionHeading icon={Lightbulb} title={guidanceTitle} iconColor={C.railCompliance} />
1183
1338
  <div style={{ display: "flex", flexDirection: "column", maxHeight: 480, overflowY: "auto" }}>
1184
1339
  {guidanceItems.map((item, i) => (
1185
1340
  <AccordionRow key={i} {...item} />
@@ -20,6 +20,17 @@ import {
20
20
  } from "lucide-react";
21
21
  import DataTable2 from "../data/DataTable2";
22
22
  import PerformanceDetailsPage from "./PerformanceDetailsPage";
23
+ import {
24
+ RANGE_TO_MS,
25
+ computeWindowLabel,
26
+ formatLocalDate,
27
+ } from "./performanceRangeFormat";
28
+ import {
29
+ SPARK_DEFAULT_AREA,
30
+ SPARK_DEFAULT_LINE,
31
+ buildSparkPaths,
32
+ trendFromPoints,
33
+ } from "./performanceSparkline";
23
34
 
24
35
  const EMPTY_STAT = { value: "", label: "", trend: "up" };
25
36
 
@@ -51,12 +62,12 @@ const C = {
51
62
  const FONT_DISPLAY = "var(--font-sans, 'Averta', ui-sans-serif, system-ui, sans-serif)";
52
63
  const FONT_BODY = "var(--font-sans, 'Averta', ui-sans-serif, system-ui, sans-serif)";
53
64
 
54
- function Sparkline({ trend = "up", width = 49.618, height = 35 }) {
65
+ function Sparkline({ trend = "up", points, width = 49.618, height = 35 }) {
55
66
  const id = React.useId();
56
- const linePath =
57
- "M1.00049 13.5213C7.85975 11.7032 8.88149 4.73912 13.7838 4.73912C19.8344 4.73912 23.6237 17.166 29.1281 17.166C34.5998 17.166 37.4149 4.88121 43.4004 8.61051C47.9764 11.4616 50.6186 1.00024 50.6186 1.00024";
58
- const areaPath =
59
- "M1.00049 16.692C8.88149 15.6975 8.88149 7.79403 13.7838 7.79403C19.8344 7.79403 23.6237 21.4034 29.1281 21.4034C34.5998 21.4034 37.4149 8.93821 43.4004 12.7223C47.9764 15.6152 50.6186 5.00024 50.6186 5.00024V36.0002C50.6186 36.0002 10.8223 36.0002 1.00049 36.0002C1.00049 28.8653 1.00049 19.4871 1.00049 16.692Z";
67
+ const built = buildSparkPaths(points);
68
+ const linePath = built ? built.line : SPARK_DEFAULT_LINE;
69
+ const areaPath = built ? built.area : SPARK_DEFAULT_AREA;
70
+ const direction = trendFromPoints(points) ?? trend;
60
71
  return (
61
72
  <svg
62
73
  width={width}
@@ -65,7 +76,7 @@ function Sparkline({ trend = "up", width = 49.618, height = 35 }) {
65
76
  fill="none"
66
77
  style={{
67
78
  flexShrink: 0,
68
- transform: trend === "down" ? "scaleX(-1)" : undefined,
79
+ transform: !built && direction === "down" ? "scaleX(-1)" : undefined,
69
80
  }}
70
81
  >
71
82
  <defs>
@@ -95,12 +106,16 @@ function Sparkline({ trend = "up", width = 49.618, height = 35 }) {
95
106
  fill="none"
96
107
  strokeLinecap="round"
97
108
  strokeLinejoin="round"
98
- strokeDasharray={trend === "down" ? "2 4" : undefined}
109
+ strokeDasharray={!built && direction === "down" ? "2 4" : undefined}
99
110
  />
100
111
  </svg>
101
112
  );
102
113
  }
103
114
 
115
+ function resolveStatTrend(trend, points) {
116
+ return trendFromPoints(points) ?? (trend === "down" ? "down" : "up");
117
+ }
118
+
104
119
  function TrendArrow({ direction = "up", color = C.muted }) {
105
120
  const upPath =
106
121
  "M12.4167 0.75H17.4167M17.4167 0.75V5.75M17.4167 0.75L10.3333 7.83333L6.16667 3.66667L0.75 9.08333";
@@ -119,7 +134,8 @@ function TrendArrow({ direction = "up", color = C.muted }) {
119
134
  );
120
135
  }
121
136
 
122
- function StatCard({ value, label, trend = "up", muted = false, beigeBg = false, style = {} }) {
137
+ function StatCard({ value, label, trend = "up", points, muted = false, beigeBg = false, style = {} }) {
138
+ const direction = resolveStatTrend(trend, points);
123
139
  return (
124
140
  <div
125
141
  style={{
@@ -135,8 +151,8 @@ function StatCard({ value, label, trend = "up", muted = false, beigeBg = false,
135
151
  }}
136
152
  >
137
153
  <div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between" }}>
138
- <Sparkline trend={trend} />
139
- <TrendArrow direction={trend} />
154
+ <Sparkline trend={direction} points={points} />
155
+ <TrendArrow direction={direction} />
140
156
  </div>
141
157
  <div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 8 }}>
142
158
  <div
@@ -157,7 +173,8 @@ function StatCard({ value, label, trend = "up", muted = false, beigeBg = false,
157
173
  );
158
174
  }
159
175
 
160
- function StatSubCell({ value, label, trend = "up", withDivider = false }) {
176
+ function StatSubCell({ value, label, trend = "up", points, withDivider = false }) {
177
+ const direction = resolveStatTrend(trend, points);
161
178
  return (
162
179
  <div
163
180
  style={{
@@ -171,8 +188,8 @@ function StatSubCell({ value, label, trend = "up", withDivider = false }) {
171
188
  }}
172
189
  >
173
190
  <div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between" }}>
174
- <Sparkline trend={trend} />
175
- <TrendArrow direction={trend} />
191
+ <Sparkline trend={direction} points={points} />
192
+ <TrendArrow direction={direction} />
176
193
  </div>
177
194
  <div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 8 }}>
178
195
  <div
@@ -517,30 +534,6 @@ const DEFAULT_RANGE_OPTIONS = [
517
534
  { id: "30d", label: "30d" },
518
535
  ];
519
536
 
520
- const RANGE_TO_MS = {
521
- "8h": 8 * 60 * 60 * 1000,
522
- "1d": 24 * 60 * 60 * 1000,
523
- "7d": 7 * 24 * 60 * 60 * 1000,
524
- "30d": 30 * 24 * 60 * 60 * 1000,
525
- };
526
-
527
- function formatLocalDate(date) {
528
- // MM/DD/YYYY in the viewer's local timezone (toLocaleDateString uses local TZ by default).
529
- return date.toLocaleDateString("en-US", {
530
- month: "2-digit",
531
- day: "2-digit",
532
- year: "numeric",
533
- });
534
- }
535
-
536
- function computeWindowLabel(windowId) {
537
- const ms = RANGE_TO_MS[windowId];
538
- if (!ms) return "";
539
- const to = new Date();
540
- const from = new Date(to.getTime() - ms);
541
- return `${formatLocalDate(from)} – ${formatLocalDate(to)}`;
542
- }
543
-
544
537
  function SearchBar() {
545
538
  return (
546
539
  <div
@@ -1245,12 +1238,16 @@ export default function PerformancePanel({
1245
1238
  </div>
1246
1239
  <div style={{ display: "flex", gap: 10, alignItems: "center" }}>
1247
1240
  {(() => {
1248
- // Custom range wins if active; otherwise the active chip drives
1249
- // the label (preserving the legacy `dateRangeLabel` override).
1241
+ // Custom range wins if active; otherwise the active chip drives the
1242
+ // label. Chip label wins over `dateRangeLabel` so hosts aren't stuck
1243
+ // showing date-only strings for 8h when they pass a legacy override.
1250
1244
  const customLabel = customRangeActive
1251
1245
  ? `${formatLocalDate(effectiveDateRange.from)} – ${formatLocalDate(effectiveDateRange.to)}`
1252
1246
  : "";
1253
- const label = customLabel || dateRangeLabel || computeWindowLabel(selectedWindow);
1247
+ const label =
1248
+ customLabel ||
1249
+ computeWindowLabel(selectedWindow) ||
1250
+ dateRangeLabel;
1254
1251
  return label ? (
1255
1252
  <span
1256
1253
  style={{
@@ -1289,8 +1286,8 @@ export default function PerformancePanel({
1289
1286
  </div>
1290
1287
 
1291
1288
  <div style={{ display: "flex", gap: 24 }}>
1292
- <StatCard value={volumeStat.value} label={volumeStat.label} trend={volumeStat.trend} style={{ width: 264, flexShrink: 0 }} />
1293
- <StatCard value={compassStat.value} label={compassStat.label} trend={compassStat.trend} style={{ width: 264, flexShrink: 0 }} />
1289
+ <StatCard value={volumeStat.value} label={volumeStat.label} trend={volumeStat.trend} points={volumeStat.points} style={{ width: 264, flexShrink: 0 }} />
1290
+ <StatCard value={compassStat.value} label={compassStat.label} trend={compassStat.trend} points={compassStat.points} style={{ width: 264, flexShrink: 0 }} />
1294
1291
  <div
1295
1292
  style={{
1296
1293
  flex: 1,
@@ -1304,9 +1301,9 @@ export default function PerformancePanel({
1304
1301
  boxSizing: "border-box",
1305
1302
  }}
1306
1303
  >
1307
- <StatSubCell value={baselineSubStat.value} label={baselineSubStat.label} trend={baselineSubStat.trend} />
1308
- <StatSubCell value={agentLiftSubStat.value} label={agentLiftSubStat.label} trend={agentLiftSubStat.trend} withDivider />
1309
- <StatSubCell value={scoreSubStat.value} label={scoreSubStat.label} trend={scoreSubStat.trend} withDivider />
1304
+ <StatSubCell value={baselineSubStat.value} label={baselineSubStat.label} trend={baselineSubStat.trend} points={baselineSubStat.points} />
1305
+ <StatSubCell value={agentLiftSubStat.value} label={agentLiftSubStat.label} trend={agentLiftSubStat.trend} points={agentLiftSubStat.points} withDivider />
1306
+ <StatSubCell value={scoreSubStat.value} label={scoreSubStat.label} trend={scoreSubStat.trend} points={scoreSubStat.points} withDivider />
1310
1307
  </div>
1311
1308
  </div>
1312
1309
 
@@ -1,3 +1,30 @@
1
1
  export { default as PerformancePanel, DEFAULT_AGENT_COLUMNS } from './PerformancePanel';
2
2
  export { default as PerformanceDetailsPage, DEFAULT_SESSION_COLUMNS } from './PerformanceDetailsPage';
3
3
  export { default as SupervisorSelect } from './SupervisorSelect';
4
+ export {
5
+ computeWindowLabel,
6
+ formatEightHourWindowLabel,
7
+ formatEightHourRangeEndpoints,
8
+ formatLocalDate,
9
+ getPresetWindowDates,
10
+ RANGE_TO_MS,
11
+ } from './performanceRangeFormat';
12
+ export {
13
+ buildSparkPaths,
14
+ normalizeSparkPoints,
15
+ trendFromPoints,
16
+ } from './performanceSparkline';
17
+ export {
18
+ METRIC_PLACEHOLDER,
19
+ formatMetricDisplay,
20
+ hasChartData,
21
+ hasMeaningfulMetric,
22
+ isNullishMetric,
23
+ resolveKpiTrend,
24
+ resolveTrend,
25
+ trendFromSignedValue,
26
+ } from './performanceMetrics';
27
+ export {
28
+ extractTrendSeries,
29
+ mapPerformanceKpiStats,
30
+ } from './performanceApiMap';