Sort metrics numerically on the CCP Metrics page

Add a natural-sort comparator for metric IDs (e.g. 2.3.6i, 5.2.6, 10.1.1)
and apply it to the metric breakdown cards, the vertical detail table, and
the forecast burndown metric dropdown. Metrics now appear in ascending
numerical order instead of arbitrary API response order.

Closes #24
This commit is contained in:
Jordan Ramos
2026-06-02 12:17:28 -06:00
parent 0cf49e6ef1
commit aae09020e6

View File

@@ -8,6 +8,26 @@ const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const TEAL = '#14B8A6'; const TEAL = '#14B8A6';
const PURPLE = '#A78BFA'; const PURPLE = '#A78BFA';
// Natural sort comparator for metric IDs like "2.3.6i", "5.2.6", "10.1.1".
// Splits on "." and compares each segment numerically (trailing letters after digits sort after pure numbers).
function compareMetricIds(a, b) {
const partsA = a.split('.');
const partsB = b.split('.');
const len = Math.max(partsA.length, partsB.length);
for (let i = 0; i < len; i++) {
const segA = partsA[i] || '';
const segB = partsB[i] || '';
const numA = parseFloat(segA) || 0;
const numB = parseFloat(segB) || 0;
if (numA !== numB) return numA - numB;
// Same numeric prefix — compare the suffix (e.g. "6i" vs "6")
const suffA = segA.replace(/^[\d.]+/, '');
const suffB = segB.replace(/^[\d.]+/, '');
if (suffA !== suffB) return suffA.localeCompare(suffB);
}
return 0;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Styles // Styles
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -106,7 +126,8 @@ function MetricBreakdownPanel({ metrics }) {
if (!metrics || metrics.length === 0) return null; if (!metrics || metrics.length === 0) return null;
// Only show metrics with non_compliant > 0 // Only show metrics with non_compliant > 0
const ncMetrics = metrics.filter(m => m.non_compliant > 0); const ncMetrics = metrics.filter(m => m.non_compliant > 0)
.sort((a, b) => compareMetricIds(a.metric_id, b.metric_id));
if (ncMetrics.length === 0) return null; if (ncMetrics.length === 0) return null;
const TOP_COUNT = 8; const TOP_COUNT = 8;
@@ -628,9 +649,10 @@ function VerticalDetailView({ vertical, onBack, onSelectMetric }) {
if (loading) return <div style={{ padding: '2rem', textAlign: 'center', color: '#64748B' }}><Loader style={{ animation: 'spin 1s linear infinite' }} /> Loading...</div>; if (loading) return <div style={{ padding: '2rem', textAlign: 'center', color: '#64748B' }}><Loader style={{ animation: 'spin 1s linear infinite' }} /> Loading...</div>;
// Filter metrics by team if a team filter is active // Filter metrics by team if a team filter is active
const displayMetrics = teamFilter const displayMetrics = (teamFilter
? metrics.filter(m => m.sub_teams && m.sub_teams.some(st => st.team === teamFilter)) ? metrics.filter(m => m.sub_teams && m.sub_teams.some(st => st.team === teamFilter))
: metrics; : metrics
).slice().sort((a, b) => compareMetricIds(a.metric_id, b.metric_id));
return ( return (
<div> <div>
@@ -1282,7 +1304,7 @@ function MetricSelector({ onMetricSelect, selectedMetric }) {
minWidth: '200px', minWidth: '200px',
}} }}
> >
{metrics.map(m => ( {metrics.slice().sort((a, b) => compareMetricIds(a.metric_id, b.metric_id)).map(m => (
<option key={m.metric_id} value={m.metric_id}> <option key={m.metric_id} value={m.metric_id}>
{m.metric_id} {m.device_count} device{m.device_count !== 1 ? 's' : ''} {m.metric_id} {m.device_count} device{m.device_count !== 1 ? 's' : ''}
</option> </option>