Fix forecast burndown chart data issues

- Fix Date object handling for resolution_date from PostgreSQL
- Fix totalAssets using per-metric summary (vcl_multi_vertical_summary)
  instead of vertical-level compliance_snapshots total_devices
- Fix duplicate current month in chart (forecast starts from next month)
- Fix multi-vertical metrics summing across all relevant verticals
- Fix bar stacking: orange (non-compliant) on bottom, blue (compliant)
  on top, both sharing same baseline (stacked to total)
- Add fill props to Bar components for correct legend colors
- Backfill historical snapshots with per-metric totalAssets
This commit is contained in:
Jordan Ramos
2026-05-20 17:28:20 -06:00
parent f9770872ba
commit e45deccdb7
3 changed files with 79 additions and 41 deletions

View File

@@ -476,7 +476,11 @@ function computeMetricForecastBurndown(currentDevices, totalAssets, historicalSn
const buckets = {}; const buckets = {};
for (const device of currentDevices) { for (const device of currentDevices) {
if (device.resolution_date == null) continue; if (device.resolution_date == null) continue;
const resMonth = device.resolution_date.slice(0, 7); // YYYY-MM // Handle both Date objects (from PostgreSQL) and YYYY-MM-DD strings
const dateStr = device.resolution_date instanceof Date
? device.resolution_date.toISOString().slice(0, 7)
: String(device.resolution_date).slice(0, 7);
const resMonth = dateStr; // YYYY-MM
if (resMonth < currentMonthStr) { if (resMonth < currentMonthStr) {
// Past-due: treat as remediated in current month // Past-due: treat as remediated in current month
buckets[currentMonthStr] = (buckets[currentMonthStr] || 0) + 1; buckets[currentMonthStr] = (buckets[currentMonthStr] || 0) + 1;
@@ -485,11 +489,16 @@ function computeMetricForecastBurndown(currentDevices, totalAssets, historicalSn
} }
} }
// Generate forecast months starting from current month, up to 12 months max // Generate forecast months starting from NEXT month, up to 12 months max
const forecast = []; const forecast = [];
let remainingNonCompliant = nonCompliant; let remainingNonCompliant = nonCompliant;
for (let i = 0; i < 12; i++) { // Account for devices remediated in the current month (past-due dates bucketed here)
if (buckets[currentMonthStr]) {
remainingNonCompliant -= buckets[currentMonthStr];
}
for (let i = 1; i <= 12; i++) {
const forecastYear = currentYear + Math.floor((currentMonth + i) / 12); const forecastYear = currentYear + Math.floor((currentMonth + i) / 12);
const forecastMonth = (currentMonth + i) % 12; const forecastMonth = (currentMonth + i) % 12;
const monthStr = formatMonth(forecastYear, forecastMonth); const monthStr = formatMonth(forecastYear, forecastMonth);

View File

@@ -1568,8 +1568,10 @@ function createVCLMultiVerticalRouter(upload) {
}); });
} }
// 2. Determine the vertical from active devices (use the first one found) // 2. Determine the vertical(s) from active devices
const vertical = activeDevices[0].vertical; // Group by vertical to handle metrics that span multiple verticals
const verticalSet = new Set(activeDevices.map(d => d.vertical));
const vertical = activeDevices[0].vertical; // primary vertical for snapshot lookup
// 3. Compute date range for 3 months of historical snapshots // 3. Compute date range for 3 months of historical snapshots
const now = new Date(); const now = new Date();
@@ -1578,22 +1580,26 @@ function createVCLMultiVerticalRouter(upload) {
const threeMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 3, 1); const threeMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 3, 1);
const startMonth = `${threeMonthsAgo.getFullYear()}-${String(threeMonthsAgo.getMonth() + 1).padStart(2, '0')}`; const startMonth = `${threeMonthsAgo.getFullYear()}-${String(threeMonthsAgo.getMonth() + 1).padStart(2, '0')}`;
// 4. Query historical snapshots for the vertical (3 months prior, excluding current month) // 4. Query historical snapshots for ALL verticals this metric spans
const verticals = [...verticalSet];
const { rows: snapshots } = await pool.query( const { rows: snapshots } = await pool.query(
`SELECT snapshot_month AS month, total_devices AS total_assets, `SELECT snapshot_month AS month,
non_compliant, compliance_pct::numeric AS compliance_pct SUM(total_devices)::int AS total_assets,
SUM(non_compliant)::int AS non_compliant,
ROUND((SUM(compliant)::numeric / NULLIF(SUM(total_devices), 0)) * 100, 1) AS compliance_pct
FROM compliance_snapshots FROM compliance_snapshots
WHERE vertical = $1 AND snapshot_month >= $2 AND snapshot_month < $3 WHERE vertical = ANY($1) AND snapshot_month >= $2 AND snapshot_month < $3
GROUP BY snapshot_month
ORDER BY snapshot_month ASC`, ORDER BY snapshot_month ASC`,
[vertical, startMonth, currentMonth] [verticals, startMonth, currentMonth]
); );
// 5. Get total non-compliant devices for the vertical (for ratio computation) // 5. Get total non-compliant devices across all verticals this metric spans
const { rows: verticalNcRows } = await pool.query( const { rows: verticalNcRows } = await pool.query(
`SELECT COUNT(DISTINCT hostname) AS total_nc `SELECT COUNT(DISTINCT hostname) AS total_nc
FROM compliance_items FROM compliance_items
WHERE vertical = $1 AND status = 'active'`, WHERE vertical = ANY($1) AND status = 'active'`,
[vertical] [verticals]
); );
const verticalTotalNc = parseInt(verticalNcRows[0].total_nc, 10) || 0; const verticalTotalNc = parseInt(verticalNcRows[0].total_nc, 10) || 0;
@@ -1601,11 +1607,11 @@ function createVCLMultiVerticalRouter(upload) {
const metricNcCount = new Set(activeDevices.map(d => d.hostname)).size; const metricNcCount = new Set(activeDevices.map(d => d.hostname)).size;
// 6. Compute per-metric historical non_compliant using the ratio method (Requirement 7.2) // 6. Compute per-metric historical non_compliant using the ratio method (Requirement 7.2)
// Use the metric's own total (from summary) rather than the vertical's total_devices
const historicalSnapshots = snapshots.map(snap => { const historicalSnapshots = snapshots.map(snap => {
const snapshotNc = parseInt(snap.non_compliant, 10) || 0; const snapshotNc = parseInt(snap.non_compliant, 10) || 0;
let metricNc; let metricNc;
if (verticalTotalNc === 0) { if (verticalTotalNc === 0) {
// Requirement 7.3: if vertical's total non_compliant is 0, metric's is 0
metricNc = 0; metricNc = 0;
} else { } else {
// Ratio method: vertical_snapshot_nc * (metric_nc / vertical_total_nc) // Ratio method: vertical_snapshot_nc * (metric_nc / vertical_total_nc)
@@ -1614,26 +1620,44 @@ function createVCLMultiVerticalRouter(upload) {
return { return {
month: snap.month, month: snap.month,
total_assets: parseInt(snap.total_assets, 10) || 0, total_assets: 0, // Will be filled in after we get the metric's totalAssets
non_compliant: metricNc, non_compliant: metricNc,
compliance_pct: parseFloat(snap.compliance_pct) || 0, compliance_pct: 0, // Will be recomputed
}; };
}); });
// 7. Include current month as the most recent historical data point (from live data) // 7. Include current month as the most recent historical data point (from live data)
// Get totalAssets from the most recent snapshot's total_devices, or from live vertical count // Get totalAssets from the per-metric summary (vcl_multi_vertical_summary)
// This gives us the actual total devices for THIS metric, not the entire vertical
let totalAssets = 0; let totalAssets = 0;
const { rows: latestSnapshotRows } = await pool.query( const { rows: metricSummaryRows } = await pool.query(
`SELECT total_devices `SELECT SUM(total)::int AS total
FROM compliance_snapshots FROM vcl_multi_vertical_summary
WHERE vertical = $1 WHERE metric_id = $1 AND team LIKE 'ALL:%'
ORDER BY snapshot_month DESC AND upload_id IN (
LIMIT 1`, SELECT id FROM compliance_uploads
[vertical] WHERE vertical IS NOT NULL
ORDER BY id DESC
LIMIT 20
)`,
[metricId]
); );
if (latestSnapshotRows.length > 0) { if (metricSummaryRows.length > 0 && metricSummaryRows[0].total) {
totalAssets = parseInt(latestSnapshotRows[0].total_devices, 10) || 0; totalAssets = parseInt(metricSummaryRows[0].total, 10) || 0;
}
// Fallback: if no summary data, use non_compliant count as minimum
if (totalAssets === 0) {
totalAssets = metricNcCount;
}
// Backfill historical snapshots with the correct per-metric totalAssets and compliance_pct
for (const snap of historicalSnapshots) {
snap.total_assets = totalAssets;
snap.compliance_pct = totalAssets > 0
? Math.round(((totalAssets - snap.non_compliant) / totalAssets) * 1000) / 10
: 0;
} }
// Current month data point from live data // Current month data point from live data

View File

@@ -1367,8 +1367,8 @@ function ForecastBurndownChart({ metricId }) {
// Combine historical and forecast into a single array with isForecast flag // Combine historical and forecast into a single array with isForecast flag
const combinedData = [ const combinedData = [
...historical.map(d => ({ ...d, isForecast: false })), ...historical.map(d => ({ ...d, compliant: (d.total_assets || 0) - (d.non_compliant || 0), isForecast: false })),
...forecast.map(d => ({ ...d, isForecast: true })), ...forecast.map(d => ({ ...d, compliant: (d.total_assets || 0) - (d.non_compliant || 0), isForecast: true })),
]; ];
// Determine the divider position (between last historical and first forecast) // Determine the divider position (between last historical and first forecast)
@@ -1399,11 +1399,12 @@ function ForecastBurndownChart({ metricId }) {
// Custom label for bars (device counts inside bars) // Custom label for bars (device counts inside bars)
const renderTotalLabel = (props) => { const renderTotalLabel = (props) => {
const { x, y, width, height, value } = props; const { x, y, width, height, payload } = props;
if (!value || height < 14) return null; const total = payload ? payload.total_assets : null;
if (!total || height < 14) return null;
return ( return (
<text x={x + width / 2} y={y + height / 2} textAnchor="middle" dominantBaseline="middle" fill="#FFF" fontSize={9} fontWeight="600"> <text x={x + width / 2} y={y + height / 2} textAnchor="middle" dominantBaseline="middle" fill="#FFF" fontSize={9} fontWeight="600">
{value} {total}
</text> </text>
); );
}; };
@@ -1487,19 +1488,23 @@ function ForecastBurndownChart({ metricId }) {
)} )}
<Bar <Bar
yAxisId="left" yAxisId="left"
dataKey="total_assets" dataKey="non_compliant"
name="Total Assets" name="Non-Compliant"
shape={renderTotalAssetsBar} stackId="devices"
label={renderTotalLabel} fill="#F97316"
barSize={28} shape={renderNonCompliantBar}
label={renderNonCompliantLabel}
barSize={36}
/> />
<Bar <Bar
yAxisId="left" yAxisId="left"
dataKey="non_compliant" dataKey="compliant"
name="Non-Compliant" name="Compliant"
shape={renderNonCompliantBar} stackId="devices"
label={renderNonCompliantLabel} fill="#3B82F6"
barSize={28} shape={renderTotalAssetsBar}
label={renderTotalLabel}
barSize={36}
/> />
<Line <Line
yAxisId="right" yAxisId="right"