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.
- package/dist/DataTable2.cjs.js +1 -1
- package/dist/DataTable2.cjs.js.map +1 -1
- package/dist/DataTable2.es.js +611 -604
- package/dist/DataTable2.es.js.map +1 -1
- package/dist/PerformancePanel.cjs.js +1 -1
- package/dist/PerformancePanel.cjs.js.map +1 -1
- package/dist/PerformancePanel.es.js +1068 -816
- package/dist/PerformancePanel.es.js.map +1 -1
- package/dist/SupervisorSelect.cjs.js +2 -0
- package/dist/SupervisorSelect.cjs.js.map +1 -0
- package/dist/SupervisorSelect.es.js +352 -0
- package/dist/SupervisorSelect.es.js.map +1 -0
- package/dist/components/Signals.cjs.js +1 -1
- package/dist/components/Signals.cjs.js.map +1 -1
- package/dist/components/Signals.es.js +273 -318
- package/dist/components/Signals.es.js.map +1 -1
- package/dist/components/UpdatedInteractionDetails.cjs.js +3 -3
- package/dist/components/UpdatedInteractionDetails.cjs.js.map +1 -1
- package/dist/components/UpdatedInteractionDetails.es.js +98 -92
- package/dist/components/UpdatedInteractionDetails.es.js.map +1 -1
- package/dist/components/performance.cjs.js +1 -1
- package/dist/components/performance.cjs.js.map +1 -1
- package/dist/components/performance.es.js +116 -349
- package/dist/components/performance.es.js.map +1 -1
- package/dist/components/reports.cjs.js +2 -2
- package/dist/components/reports.cjs.js.map +1 -1
- package/dist/components/reports.es.js +122 -141
- package/dist/components/reports.es.js.map +1 -1
- package/dist/index.cjs.js +1 -1
- package/dist/index.es.js +1 -1
- package/dist/pages/interactionDetails.cjs.js +2 -2
- package/dist/pages/interactionDetails.cjs.js.map +1 -1
- package/dist/pages/interactionDetails.es.js +130 -124
- package/dist/pages/interactionDetails.es.js.map +1 -1
- package/package.json +1 -1
- package/src/components/Signals/SignalDetailsPage.jsx +2 -29
- package/src/components/Signals/SignalsListPage.jsx +2 -24
- package/src/components/UpdatedInteractionDetails/UpdatedCoachingSynthesisCard.jsx +8 -1
- package/src/components/data/DataTable2.jsx +19 -5
- package/src/components/pages/interactionDetails/CoachingSynthesisCard.jsx +8 -1
- package/src/components/performance/PerformanceDetailsPage.jsx +230 -75
- package/src/components/performance/PerformancePanel.jsx +42 -45
- package/src/components/performance/index.js +27 -0
- package/src/components/performance/performanceApiMap.js +133 -0
- package/src/components/performance/performanceMetrics.js +63 -0
- package/src/components/performance/performanceRangeFormat.js +92 -0
- package/src/components/performance/performanceSparkline.js +52 -0
- 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
|
-
|
|
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
|
-
<
|
|
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
|
-
{
|
|
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
|
-
|
|
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:
|
|
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 }}>{
|
|
498
|
-
|
|
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
|
|
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
|
-
|
|
662
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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.
|
|
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
|
|
57
|
-
|
|
58
|
-
const areaPath =
|
|
59
|
-
|
|
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:
|
|
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={
|
|
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={
|
|
139
|
-
<TrendArrow direction={
|
|
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={
|
|
175
|
-
<TrendArrow direction={
|
|
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
|
-
//
|
|
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 =
|
|
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';
|