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 = {};
for (const device of currentDevices) {
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) {
// Past-due: treat as remediated in current month
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 = [];
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 forecastMonth = (currentMonth + i) % 12;
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)
const vertical = activeDevices[0].vertical;
// 2. Determine the vertical(s) from active devices
// 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
const now = new Date();
@@ -1578,22 +1580,26 @@ function createVCLMultiVerticalRouter(upload) {
const threeMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 3, 1);
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(
`SELECT snapshot_month AS month, total_devices AS total_assets,
non_compliant, compliance_pct::numeric AS compliance_pct
`SELECT snapshot_month AS month,
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
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`,
[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(
`SELECT COUNT(DISTINCT hostname) AS total_nc
FROM compliance_items
WHERE vertical = $1 AND status = 'active'`,
[vertical]
WHERE vertical = ANY($1) AND status = 'active'`,
[verticals]
);
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;
// 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 snapshotNc = parseInt(snap.non_compliant, 10) || 0;
let metricNc;
if (verticalTotalNc === 0) {
// Requirement 7.3: if vertical's total non_compliant is 0, metric's is 0
metricNc = 0;
} else {
// Ratio method: vertical_snapshot_nc * (metric_nc / vertical_total_nc)
@@ -1614,26 +1620,44 @@ function createVCLMultiVerticalRouter(upload) {
return {
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,
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)
// 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;
const { rows: latestSnapshotRows } = await pool.query(
`SELECT total_devices
FROM compliance_snapshots
WHERE vertical = $1
ORDER BY snapshot_month DESC
LIMIT 1`,
[vertical]
const { rows: metricSummaryRows } = await pool.query(
`SELECT SUM(total)::int AS total
FROM vcl_multi_vertical_summary
WHERE metric_id = $1 AND team LIKE 'ALL:%'
AND upload_id IN (
SELECT id FROM compliance_uploads
WHERE vertical IS NOT NULL
ORDER BY id DESC
LIMIT 20
)`,
[metricId]
);
if (latestSnapshotRows.length > 0) {
totalAssets = parseInt(latestSnapshotRows[0].total_devices, 10) || 0;
if (metricSummaryRows.length > 0 && metricSummaryRows[0].total) {
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