Constconst StatsPanel: FC = () => {
const appContext = useAppContext();
const [stats, setStats] = useState<SupplierStatsData>({});
const [activeTab, setActiveTab] = useState(0);
const [pieView, setPieView] = useState<PieView>('http');
const [range, setRange] = useState<StatsRange>('all');
useEffect(() => {
const loadStats = async () => {
try {
const data = await getStats();
setStats(data);
} catch (error) {
console.warn(error);
}
};
loadStats();
// Re-read stats when supplier stats are updated in IndexedDB (live updates during search)
const handler = () => loadStats();
window.addEventListener(IDB_SUPPLIER_STATS_UPDATED, handler);
return () => window.removeEventListener(IDB_SUPPLIER_STATS_UPDATED, handler);
}, []);
const handleClear = async () => {
try {
await clearStats();
setStats({});
} catch (error) {
console.warn(error);
}
};
// Stats are keyed by supplierName ("Carolina"), but userSettings.disabledSuppliers
// holds class names ("SupplierCarolina"), so translate before comparing.
const disabledSupplierNames = useMemo(() => {
const disabledClassNames = appContext?.userSettings?.suppliers?.disabled ?? [];
const displayNames = SupplierFactory.supplierDisplayNames();
return new Set(disabledClassNames.map((className) => displayNames[className] ?? className));
}, [appContext?.userSettings?.suppliers?.disabled]);
// Every view (pies, daily chart, totals) reflects the selected range.
const rangedStats = useMemo(() => filterStatsByRange(stats, range), [stats, range]);
// Aggregate stats across the days in range
const aggregatedBySupplier = useMemo(() => {
const agg: Record<
string,
{ success: number; failure: number; products: number; queries: number; parseErrors: number }
> = {};
for (const dayStats of Object.values(rangedStats)) {
for (const [supplier, s] of Object.entries(dayStats)) {
if (!agg[supplier])
agg[supplier] = { success: 0, failure: 0, products: 0, queries: 0, parseErrors: 0 };
agg[supplier].success += s.successCount;
agg[supplier].failure += s.failureCount;
agg[supplier].products += s.uniqueProductCount;
agg[supplier].queries += s.searchQueryCount ?? 0;
agg[supplier].parseErrors += s.parseErrorCount ?? 0;
}
}
return agg;
}, [rangedStats]);
// Build supplier color map
const supplierColorMap = useMemo(() => {
const suppliers = Object.keys(aggregatedBySupplier);
const colorMap: Record<string, string> = {};
suppliers.forEach((s, i) => {
colorMap[s] = SUPPLIER_COLORS[i % SUPPLIER_COLORS.length];
});
return colorMap;
}, [aggregatedBySupplier]);
// ── HTTP Calls view: inner = total HTTP calls per supplier, outer = success vs failure ──
const httpPieData = useMemo(() => {
const suppliers = Object.keys(aggregatedBySupplier);
const totalCalls = suppliers.reduce(
(sum, s) => sum + aggregatedBySupplier[s].success + aggregatedBySupplier[s].failure,
0,
);
const inner: PieDatum[] = suppliers.map((supplier) => {
const { success, failure } = aggregatedBySupplier[supplier];
const total = success + failure;
return {
id: supplier,
value: total,
label: `${supplier}:`,
percentage: totalCalls > 0 ? (total / totalCalls) * 100 : 0,
color: supplierColorMap[supplier],
};
});
// Exactly 2 entries per supplier (Yes/No pattern from Titanic example) so outer aligns with inner
const outer: PieDatum[] = suppliers.flatMap((supplier) => {
const { success, failure } = aggregatedBySupplier[supplier];
const supplierTotal = success + failure;
const baseColor = supplierColorMap[supplier];
return [
{
id: `${supplier}-success`,
label: i18n('stats_successes'),
value: success,
percentage: supplierTotal > 0 ? (success / supplierTotal) * 100 : 0,
color: baseColor,
},
{
id: `${supplier}-failure`,
label: i18n('stats_fails'),
value: failure,
percentage: supplierTotal > 0 ? (failure / supplierTotal) * 100 : 0,
color: hexToRgba(baseColor, 0.4),
},
];
});
return { inner, outer, totalCalls };
}, [aggregatedBySupplier, supplierColorMap]);
// ── Parsed Data view: inner = total products + parse errors per supplier, outer = products vs parseErrors ──
const parsedPieData = useMemo(() => {
const suppliers = Object.keys(aggregatedBySupplier);
const totalParsed = suppliers.reduce(
(sum, s) => sum + aggregatedBySupplier[s].products + aggregatedBySupplier[s].parseErrors,
0,
);
const inner: PieDatum[] = suppliers.map((supplier) => {
const { products, parseErrors } = aggregatedBySupplier[supplier];
const total = products + parseErrors;
return {
id: supplier,
value: total,
label: `${supplier}:`,
percentage: totalParsed > 0 ? (total / totalParsed) * 100 : 0,
color: supplierColorMap[supplier],
};
});
const outer: PieDatum[] = suppliers.flatMap((supplier) => {
const { products, parseErrors } = aggregatedBySupplier[supplier];
const supplierTotal = products + parseErrors;
const baseColor = supplierColorMap[supplier];
return [
{
id: `${supplier}-parsed`,
label: i18n('stats_successes'),
value: products,
percentage: supplierTotal > 0 ? (products / supplierTotal) * 100 : 0,
color: baseColor,
},
{
id: `${supplier}-parseError`,
label: i18n('stats_fails'),
value: parseErrors,
percentage: supplierTotal > 0 ? (parseErrors / supplierTotal) * 100 : 0,
color: hexToRgba(baseColor, 0.4),
},
];
});
return { inner, outer, totalParsed };
}, [aggregatedBySupplier, supplierColorMap]);
// Line chart data — one line per supplier showing total calls over time
const { lineSeries, lineDates } = useMemo(() => {
const sortedDates = Object.keys(rangedStats).sort();
const allSuppliers = new Set<string>();
for (const dayStats of Object.values(rangedStats)) {
for (const supplier of Object.keys(dayStats)) {
allSuppliers.add(supplier);
}
}
const series: Array<{
data: number[];
label: string;
color: string;
showMark: boolean;
valueFormatter: (v: number | null) => string;
}> = [];
let colorIdx = 0;
for (const supplier of allSuppliers) {
const color = SUPPLIER_COLORS[colorIdx % SUPPLIER_COLORS.length];
series.push({
data: sortedDates.map((date) => {
const s = stats[date]?.[supplier];
return s ? s.successCount + s.failureCount : 0;
}),
label: supplier,
color,
showMark: true,
valueFormatter: (v) => i18n('stats_calls', [String(v ?? 0)]),
});
colorIdx++;
}
return { lineSeries: series, lineDates: sortedDates.map((d) => d.slice(5)) };
}, [rangedStats]);
// Totals table
const totalsColumns: GridColDef[] = [
{ field: 'supplier', headerName: i18n('stats_col_supplier'), flex: 1, minWidth: 130 },
{ field: 'queries', headerName: i18n('stats_col_queries'), width: 80, type: 'number' },
{ field: 'success', headerName: i18n('stats_col_success'), width: 80, type: 'number' },
{ field: 'failure', headerName: i18n('stats_col_failure'), width: 80, type: 'number' },
{ field: 'products', headerName: i18n('stats_col_products'), width: 85, type: 'number' },
{
field: 'parseErrors',
headerName: i18n('stats_col_parse_errors'),
width: 105,
type: 'number',
},
{
field: 'status',
headerName: i18n('stats_col_status'),
width: 150,
valueFormatter: (value: SupplierHealthStatus) => i18n(statusLabelKey(value)),
},
];
// Dim, theme-aware row tints. Deliberately low-alpha: these mark rows worth a
// look, they shouldn't shout over the numbers themselves.
const statusRowSx = {
'& .stats-row--noSuccess': {
backgroundColor: (theme: Theme) => alpha(theme.palette.error.main, 0.16),
},
'& .stats-row--connectionErrors': {
backgroundColor: (theme: Theme) => alpha(theme.palette.error.main, 0.09),
},
'& .stats-row--parseFailure': {
backgroundColor: (theme: Theme) => alpha(theme.palette.error.main, 0.16),
},
'& .stats-row--parseErrors': {
backgroundColor: (theme: Theme) => alpha(theme.palette.warning.main, 0.12),
},
// Not a fault — just greyed back so it reads as inactive rather than broken.
'& .stats-row--disabled': {
backgroundColor: (theme: Theme) => alpha(theme.palette.text.disabled, 0.08),
color: 'text.disabled',
},
};
const totalsRows = useMemo(
() =>
Object.entries(aggregatedBySupplier).map(([supplier, s]) => {
const { status } = classifySupplierHealth(s, disabledSupplierNames.has(supplier));
return {
id: supplier,
supplier,
queries: s.queries,
success: s.success,
failure: s.failure,
products: s.products,
parseErrors: s.parseErrors,
status,
};
}),
[aggregatedBySupplier, disabledSupplierNames],
);
const hasData = Object.keys(stats).length > 0;
const hasRangedData = Object.keys(rangedStats).length > 0;
const totalCalls = Object.values(aggregatedBySupplier).reduce(
(sum, s) => sum + s.success + s.failure,
0,
);
const innerRadius = 50;
const middleRadius = 120;
// Select the right pie data based on the toggle
const activePie = pieView === 'http' ? httpPieData : parsedPieData;
const centerLabel = pieView === 'http' ? i18n('stats_center_http') : i18n('stats_center_parsed');
const activeTotalForTooltip =
pieView === 'http' ? httpPieData.totalCalls : parsedPieData.totalParsed;
return (
<div className={styles['stats-panel']}>
{/* Header */}
<div className={styles['stats-panel__top-header']}>
<div className={styles['header-left']}>
{appContext?.setPanel && (
<BackButton
onClick={() => appContext.setPanel!(0)}
size="small"
aria-label={i18n('common_back_to_search')}
>
<ArrowBackIcon />
</BackButton>
)}
<Typography variant="subtitle2">{i18n('stats_title')}</Typography>
</div>
<div className={styles['header-right']}>
<TextField
select
size="small"
variant="standard"
value={range}
onChange={(event) => setRange(event.target.value as StatsRange)}
aria-label={i18n('stats_range_label')}
slotProps={{ input: { disableUnderline: true } }}
sx={{ minWidth: 110, '& .MuiSelect-select': { fontSize: '0.75rem', py: 0.25 } }}
>
{STATS_RANGES.map(({ value, labelKey }) => (
<MenuItem key={value} value={value} sx={{ fontSize: '0.75rem' }}>
{i18n(labelKey)}
</MenuItem>
))}
</TextField>
<Typography variant="caption" color="text.secondary">
{totalCalls === 1
? i18n('stats_call_single', [String(totalCalls)])
: i18n('stats_calls', [String(totalCalls)])}
</Typography>
{hasData && (
<Tooltip title={i18n('stats_clear')}>
<IconButton
size="small"
onClick={handleClear}
className={styles['stats-panel__clear-btn']}
>
<DeleteIcon className={styles['stats-panel__clear-icon']} />
</IconButton>
</Tooltip>
)}
</div>
</div>
{!hasData ? (
<Typography variant="body2" color="text.secondary" className={styles['stats-panel__empty']}>
{i18n('stats_empty')}
</Typography>
) : (
<>
<Tabs
value={activeTab}
onChange={(_e, v) => setActiveTab(v)}
variant="fullWidth"
className={styles['stats-panel__tabs']}
>
<Tab label={i18n('stats_tab_by_supplier')} />
<Tab label={i18n('stats_tab_daily')} />
<Tab label={i18n('stats_tab_totals')} />
</Tabs>
<Paper className={styles['stats-panel__content']} elevation={2}>
{/* A range with no recorded days would otherwise render empty charts. */}
{!hasRangedData && (
<Typography
variant="body2"
color="text.secondary"
className={styles['stats-panel__empty']}
>
{i18n('stats_empty_range')}
</Typography>
)}
{/* Tab 0: Sunburst pie with toggle */}
{hasRangedData && activeTab === 0 && (
<>
<Box className={styles['stats-panel__toggle-container']}>
<ToggleButtonGroup
color="primary"
size="small"
value={pieView}
exclusive
onChange={(_e, v) => {
if (v !== null) setPieView(v);
}}
>
<ToggleButton value="http">{i18n('stats_toggle_http')}</ToggleButton>
<ToggleButton value="parsed">{i18n('stats_toggle_parsed')}</ToggleButton>
</ToggleButtonGroup>
</Box>
<Box className={styles['stats-panel__chart-container']}>
<PieChart
series={[
{
innerRadius,
outerRadius: middleRadius,
data: activePie.inner.map((d) => ({ ...d, label: d.id })),
valueFormatter: ({ value }) =>
i18n('stats_pie_inner_tooltip', [
String(value),
String(activeTotalForTooltip),
String(
activeTotalForTooltip > 0
? ((value / activeTotalForTooltip) * 100).toFixed(0)
: 0,
),
]),
highlightScope: { fade: 'global', highlight: 'item' },
highlighted: { additionalRadius: 2 },
cornerRadius: 3,
paddingAngle: 2,
},
{
innerRadius: middleRadius,
outerRadius: middleRadius + 20,
data: activePie.outer,
valueFormatter: (item) =>
// MUI's pie item type omits our custom PieDatum fields; the runtime
// data is the PieDatum[] we passed, so reading `percentage` is safe.
i18n('stats_pie_outer_tooltip', [
String(item.value),
String((item as unknown as PieDatum).percentage?.toFixed(0) ?? 0),
]),
highlightScope: { fade: 'global', highlight: 'item' },
highlighted: { additionalRadius: 2 },
cornerRadius: 3,
paddingAngle: 1,
},
]}
width={500}
height={300}
hideLegend
>
<PieCenterLabel>{centerLabel}</PieCenterLabel>
</PieChart>
</Box>
{/* Custom legend for supplier colors only */}
<Box className={styles['stats-panel__legend']}>
{activePie.inner.map((d) => (
<Box key={d.id} className={styles['stats-panel__legend-item']}>
<Box
className={styles['stats-panel__legend-dot']}
style={{ backgroundColor: d.color }}
/>
<span>{d.id}</span>
</Box>
))}
</Box>
</>
)}
{/* Tab 1: Line chart — daily calls per supplier */}
{hasRangedData && activeTab === 1 && (
<Box className={styles['stats-panel__chart-container']}>
{lineDates.length > 0 && lineSeries.length > 0 && (
<LineChart
xAxis={[{ scaleType: 'point', data: lineDates }]}
series={lineSeries}
width={500}
height={320}
hideLegend
/>
)}
{/* Custom legend */}
<Box className={styles['stats-panel__legend']}>
{lineSeries.map((s) => (
<Box key={s.label} className={styles['stats-panel__legend-item']}>
<Box
className={styles['stats-panel__legend-dot']}
style={{ backgroundColor: s.color }}
/>
<span>{s.label}</span>
</Box>
))}
</Box>
</Box>
)}
{/* Tab 2: Totals table */}
{hasRangedData && activeTab === 2 && (
<div className={styles['stats-panel__table-container']}>
<DataGrid
rows={totalsRows}
columns={totalsColumns}
density="compact"
disableColumnMenu
hideFooter={totalsRows.length <= 25}
initialState={{
sorting: { sortModel: [{ field: 'success', sort: 'desc' }] },
}}
getRowClassName={({ row }) => `stats-row--${row.status}`}
sx={statusRowSx}
className={styles['stats-panel__table']}
/>
</div>
)}
</Paper>
</>
)}
</div>
);
};
StatsPanel displays per-supplier search statistics as a full panel view. Three tabs: By Supplier (pie), Daily (bar), Totals (text). The pie chart has two views toggled like the Titanic example: