Gov Contracting Dashboard
Contract Spending Over Time
Showing quarterly spending for the past 5 years
Spending Distribution
Showing distribution by business size
+ val.toLocaleString();
}
}
}
},
tension: 0.4,
elements: {
point: {
radius: 3,
hoverRadius: 6
},
line: {
borderWidth: 3
}
}
}
});
// Enhanced donut chart configuration
donutChart = new Chart(donutCtx, {
type: ‘doughnut’,
data: { labels: [], datasets: [] },
options: {
responsive: true,
maintainAspectRatio: false,
onClick: function(e, elements) {
console.log(‘Donut chart clicked’, e, elements);
handleDonutChartClick(e);
},
cutout: ‘60%’,
plugins: {
legend: {
position: ‘bottom’,
labels: {
boxWidth: 12,
padding: 15,
usePointStyle: true,
pointStyle: ‘circle’
}
},
tooltip: {
callbacks: {
label: (context) => {
const val = context.parsed || 0;
const label = context.label || ”;
const total = context.dataset.data.reduce((s, v) => s + v, 0);
const pct = total > 0 ? ((val / total) * 100).toFixed(1) : 0;
return `${label}: ${val.toLocaleString()} (${pct}%)`;
}
}
}
},
animation: {
animateRotate: true,
animateScale: true
}
}
});
console.log(‘Charts initialized successfully’);
} catch (error) {
console.error(‘Error initializing charts:’, error);
showError(‘There was an error initializing the charts. Please refresh the page and try again.’);
showLoading(false);
}
}
// Load data with default filters
async function loadInitialData() {
try {
showLoading(true);
// Build default time period – last 5 fiscal years
const timePeriod = getTimePeriod(5);
currentFilters = {
time_period: [timePeriod],
award_type_codes: [“A”, “B”, “C”, “D”] // Contract award types
};
await fetchDataAndRender();
} catch (error) {
console.error(‘Error loading initial data:’, error);
showError(‘There was an error loading the dashboard data. Please try again later.’);
} finally {
showLoading(false);
}
}
// Calculate the time period based on fiscal years
function getTimePeriod(yearRange) {
const currentDate = new Date();
let fiscalYear = currentDate.getFullYear();
const currentMonth = currentDate.getMonth(); // 0-indexed
// If before October, we’re in the previous fiscal year
if (currentMonth ({…r, type: ‘small’})),
…otherBizData.results.map(r => ({…r, type: ‘other’}))
].sort((a, b) => {
if (a.time_period.fiscal_year !== b.time_period.fiscal_year) {
return a.time_period.fiscal_year – b.time_period.fiscal_year;
}
return a.time_period.quarter – b.time_period.quarter;
});
// Get unique sorted quarters
const uniqueQuarters = […new Set(combinedResults.map(r =>
`${r.time_period.fiscal_year}-Q${r.time_period.quarter}`
))].sort();
// Create data points for each quarter
uniqueQuarters.forEach(quarter => {
quarterLabels.push(quarter);
const smallResult = smallBizData.results.find(r =>
`${r.time_period.fiscal_year}-Q${r.time_period.quarter}` === quarter
);
const otherResult = otherBizData.results.find(r =>
`${r.time_period.fiscal_year}-Q${r.time_period.quarter}` === quarter
);
smallAmounts.push(smallResult ? Math.round(smallResult.aggregated_amount) : 0);
otherAmounts.push(otherResult ? Math.round(otherResult.aggregated_amount) : 0);
});
// Store current line chart data for reset functionality
currentLineData = {
labels: quarterLabels,
datasets: [
{
label: ‘Small Business’,
data: smallAmounts,
borderColor: chartColors.small,
backgroundColor: `${chartColors.small}20`,
pointBackgroundColor: chartColors.small,
borderWidth: 3,
fill: true
},
{
label: ‘Other-than-Small’,
data: otherAmounts,
borderColor: chartColors.other,
backgroundColor: `${chartColors.other}20`,
pointBackgroundColor: chartColors.other,
borderWidth: 3,
fill: true
}
]
};
// Update line chart
lineChart.data = currentLineData;
lineChart.update();
// 2. Donut chart: small vs other
const totalSmall = smallAmounts.reduce((s,v)=>s+v,0);
const totalOther = otherAmounts.reduce((s,v)=>s+v,0);
// Store current donut chart data for reset functionality
currentDonutData = {
labels: [‘Small Business’,’Other-than-Small’],
datasets: [{
data: [totalSmall, totalOther],
backgroundColor: [chartColors.small, chartColors.other],
borderColor: [‘#ffffff’, ‘#ffffff’],
borderWidth: 2,
hoverOffset: 15
}]
};
// Update donut chart
donutChart.data = currentDonutData;
donutChart.update();
// 3. Update chart subtitles
updateChartSubtitles();
// 4. Get contract counts and vendor data
await fetchContractCounts(totalSmall, totalOther);
} catch (error) {
console.error(‘Error fetching data:’, error);
showError(‘Error retrieving data from USASpending. Please try again or adjust your search criteria.’);
// Initialize charts with empty data
initializeEmptyCharts();
}
}
// Fetch spending over time data from USASpending API
async function fetchSpendingOverTime(filters) {
const requestBody = {
group: “quarter”,
filters: filters,
subawards: false
};
try {
const response = await fetch(SPENDING_OVER_TIME_ENDPOINT, {
method: ‘POST’,
headers: {
‘Content-Type’: ‘application/json’
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
throw new Error(`API request failed with status ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error(‘Error fetching spending over time:’, error);
throw error;
}
}
// Fetch contract counts by category (award counts, vendor counts)
async function fetchContractCounts(totalSmall, totalOther) {
try {
// Get contract counts – small business vs other
const contractCountRequest = {
filters: {…currentFilters},
category: “award_count”
};
const smallVendorRequest = {
filters: {…currentFilters, business_size: “S”},
category: “recipient_duns”
};
// Use spending by category endpoint to get counts
const [countResponse, vendorResponse] = await Promise.all([
fetch(SPENDING_BY_CATEGORY_ENDPOINT, {
method: ‘POST’,
headers: { ‘Content-Type’: ‘application/json’ },
body: JSON.stringify(contractCountRequest)
}),
fetch(SPENDING_BY_CATEGORY_ENDPOINT, {
method: ‘POST’,
headers: { ‘Content-Type’: ‘application/json’ },
body: JSON.stringify(smallVendorRequest)
})
]);
if (!countResponse.ok || !vendorResponse.ok) {
throw new Error(‘Failed to fetch count data’);
}
const countData = await countResponse.json();
const vendorData = await vendorResponse.json();
// Total contract count
const totalContracts = countData?.results?.length > 0
? countData.results[0].count
: 0;
// Small vendor count (unique recipients)
const smallVendors = vendorData?.results?.length || 0;
// Update KPIs
updateKPIs({
totalValue: totalSmall + totalOther,
contractCount: totalContracts,
smallBizValue: totalSmall,
smallVendorCount: smallVendors
});
// Calculate and display previous period comparison
if (chartState.previousData) {
calculatePeriodComparison(totalSmall, totalOther, totalContracts, smallVendors);
}
} catch (error) {
console.error(‘Error fetching contract counts:’, error);
// Use available data for KPIs even if count request fails
updateKPIs({
totalValue: totalSmall + totalOther,
contractCount: 0,
smallBizValue: totalSmall,
smallVendorCount: 0
});
}
}
// Calculate period-over-period comparison for KPIs
async function calculatePeriodComparison(totalSmall, totalOther, totalContracts, smallVendors) {
try {
// Only attempt if we have previous period data
if (!chartState.previousData?.time_period) return;
// Create filters for previous period with same length
const prevStartDate = new Date(chartState.previousData.time_period.start_date);
const prevEndDate = new Date(chartState.previousData.time_period.end_date);
// Calculate previous period (same length, one period earlier)
const periodLength = (prevEndDate – prevStartDate) / (1000 * 60 * 60 * 24);
const newPrevStart = new Date(prevStartDate);
newPrevStart.setDate(newPrevStart.getDate() – periodLength);
const prevFilters = {
…currentFilters,
time_period: [{
start_date: newPrevStart.toISOString().split(‘T’)[0],
end_date: prevStartDate.toISOString().split(‘T’)[0]
}]
};
// Get previous period data
const [prevSmallData, prevOtherData] = await Promise.all([
fetchSpendingOverTime({
…prevFilters,
business_size: ‘S’
}),
fetchSpendingOverTime({
…prevFilters,
business_size: ‘O’
})
]);
// Calculate previous totals
const prevSmallTotal = prevSmallData?.results?.reduce((total, item) =>
total + (item.aggregated_amount || 0), 0) || 0;
const prevOtherTotal = prevOtherData?.results?.reduce((total, item) =>
total + (item.aggregated_amount || 0), 0) || 0;
// Calculate percent changes
const totalChange = calculatePercentChange(prevSmallTotal + prevOtherTotal, totalSmall + totalOther);
const smallBizChange = calculatePercentChange(prevSmallTotal, totalSmall);
// Update KPI change indicators
document.getElementById(‘kpiTotalChange’).textContent = formatChange(totalChange);
document.getElementById(‘kpiTotalChange’).className = `kpi-change ${totalChange >= 0 ? ‘positive’ : ‘negative’}`;
document.getElementById(‘kpiSmallBizChange’).textContent = formatChange(smallBizChange);
document.getElementById(‘kpiSmallBizChange’).className = `kpi-change ${smallBizChange >= 0 ? ‘positive’ : ‘negative’}`;
// For contract and vendor counts, show placeholder change values
document.getElementById(‘kpiContractsChange’).textContent = ‘from previous period’;
document.getElementById(‘kpiContractsChange’).className = ‘kpi-change’;
document.getElementById(‘kpiSmallVendorsChange’).textContent = ‘from previous period’;
document.getElementById(‘kpiSmallVendorsChange’).className = ‘kpi-change’;
} catch (error) {
console.error(‘Error calculating period comparison:’, error);
// Set default change values
setDefaultKpiChanges();
}
}
// Calculate percent change between two values
function calculatePercentChange(oldValue, newValue) {
if (oldValue === 0) return newValue > 0 ? 100 : 0;
return ((newValue – oldValue) / oldValue) * 100;
}
// Format percent change for display
function formatChange(percentChange) {
const sign = percentChange >= 0 ? ‘+’ : ”;
return `${sign}${percentChange.toFixed(1)}% from previous period`;
}
// Set default percent changes for KPIs
function setDefaultKpiChanges() {
document.getElementById(‘kpiTotalChange’).textContent = ‘from previous period’;
document.getElementById(‘kpiTotalChange’).className = ‘kpi-change’;
document.getElementById(‘kpiContractsChange’).textContent = ‘from previous period’;
document.getElementById(‘kpiContractsChange’).className = ‘kpi-change’;
document.getElementById(‘kpiSmallBizChange’).textContent = ‘from previous period’;
document.getElementById(‘kpiSmallBizChange’).className = ‘kpi-change’;
document.getElementById(‘kpiSmallVendorsChange’).textContent = ‘from previous period’;
document.getElementById(‘kpiSmallVendorsChange’).className = ‘kpi-change’;
}
// Initialize charts with empty data (used when API fails)
function initializeEmptyCharts() {
// Reset line chart
lineChart.data = {
labels: [],
datasets: [
{
label: ‘Small Business’,
data: [],
borderColor: chartColors.small,
backgroundColor: `${chartColors.small}20`,
},
{
label: ‘Other-than-Small’,
data: [],
borderColor: chartColors.other,
backgroundColor: `${chartColors.other}20`,
}
]
};
lineChart.update();
// Reset donut chart
donutChart.data = {
labels: [‘Small Business’, ‘Other-than-Small’],
datasets: [{
data: [0, 0],
backgroundColor: [chartColors.small, chartColors.other],
borderColor: [‘#ffffff’, ‘#ffffff’],
borderWidth: 2
}]
};
donutChart.update();
// Reset KPIs
updateKPIs({
totalValue: 0,
contractCount: 0,
smallBizValue: 0,
smallVendorCount: 0
});
setDefaultKpiChanges();
}
// Handle line chart click events
function handleLineChartClick(evt) {
const points = lineChart.getElementsAtEventForMode(evt, ‘nearest’, { intersect: true }, true);
if (!points.length) return;
const idx = points[0].index;
const datasetIdx = points[0].datasetIndex;
const quarterLabel = lineChart.data.labels[idx];
// If clicking the same quarter again, reset the view
if (chartState.selectedQuarter === quarterLabel) {
resetDonutChart();
return;
}
chartState.selectedQuarter = quarterLabel;
document.getElementById(‘lineChartSubtitle’).textContent = `Focusing on ${quarterLabel}`;
// Extract fiscal year and quarter
const [fyStr, qStr] = quarterLabel.split(‘-Q’);
// If we’re already in set-aside drill-down mode
if (chartState.drilledIntoSetAside) {
// Update the set-aside breakdown for this specific quarter
showSetAsideBreakdown(parseInt(fyStr, 10), parseInt(qStr, 10));
} else {
// Regular quarter drill-down
drillDonutToQuarter(parseInt(fyStr, 10), parseInt(qStr, 10));
}
}
// Handle donut chart click events
function handleDonutChartClick(evt) {
const activePoints = donutChart.getElementsAtEventForMode(evt, ‘nearest’, { intersect: true }, true);
if (!activePoints.length) return;
const idx = activePoints[0].index;
const label = donutChart.data.labels[idx];
// If not yet drilled into set-asides and clicked on Small Business slice
if (!chartState.drilledIntoSetAside && label === ‘Small Business’) {
chartState.drilledIntoSetAside = true;
showSetAsideBreakdown();
document.getElementById(‘donutChartSubtitle’).textContent = ‘Small Business Set-Aside Programs’;
}
// If already in set-aside view and clicked on a specific set-aside
else if (chartState.drilledIntoSetAside) {
const setAsideCode = Object.keys(setAsideMap).find(code =>
setAsideMap[code].label === label
);
if (setAsideCode) {
drillLineChartToSetAside(setAsideCode, label);
}
}
// If not drilled in and clicked other-than-small
else if (!chartState.drilledIntoSetAside && label === ‘Other-than-Small’) {
alert(‘No further breakdown for Other-than-Small Business contracts is available.’);
}
}
// Show donut breakdown for small business set-asides
async function showSetAsideBreakdown(fiscalYear = null, quarter = null) {
try {
showLoading(true);
// Create base filters – either for specific quarter or whole period
let setAsideFilters = {…currentFilters};
if (fiscalYear !== null && quarter !== null) {
// For specific quarter
setAsideFilters = {
…currentFilters,
time_period: [{
start_date: `${fiscalYear}-${quarter * 3 – 2}-01`,
end_date: `${fiscalYear}-${quarter * 3}-${quarter === 4 ? ’30’ : ’31’}`
}]
};
}
// Set business size to small
setAsideFilters.business_size = “S”;
// Use spending by category endpoint to get set-aside breakdown
const requestBody = {
filters: setAsideFilters,
category: “type_of_set_aside”,
subawards: false
};
const response = await fetch(SPENDING_BY_CATEGORY_ENDPOINT, {
method: ‘POST’,
headers: {
‘Content-Type’: ‘application/json’
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
throw new Error(`API request failed with status ${response.status}`);
}
const data = await response.json();
// Transform API results to our format
let results = [];
if (data.results && data.results.length > 0) {
data.results.forEach(item => {
// Map API set-aside codes to our set-aside map
const code = item.code;
const setAside = setAsideMap[code] || {
code: code,
label: item.name || code,
description: “Small business set-aside program”
};
results.push({
code: code,
label: setAside.label,
description: setAside.description,
amount: Math.round(item.amount)
});
});
}
// Add “Other Small Business” category if we have any small business spending
const lineChartData = lineChart.data.datasets[0].data;
const totalSmall = lineChartData.reduce((sum, val) => sum + val, 0);
const totalSetAside = results.reduce((sum, item) => sum + item.amount, 0);
if (totalSmall > totalSetAside) {
results.push({
code: “OTHER_SMALL”,
label: “Other Small Business”,
description: “Small businesses without set-aside designation”,
amount: Math.round(totalSmall – totalSetAside)
});
}
// Sort by amount descending
results.sort((a, b) => b.amount – a.amount);
// Limit to top 6 categories to fit our color scheme
if (results.length > 6) {
const otherAmount = results.slice(5).reduce((sum, item) => sum + item.amount, 0);
results = results.slice(0, 5);
results.push({
code: “OTHER”,
label: “Other Set-Asides”,
description: “Other small business set-aside programs”,
amount: otherAmount
});
}
// Update donut chart with set-aside data
donutChart.data.labels = results.map(r => r.label);
donutChart.data.datasets[0].data = results.map(r => r.amount);
donutChart.data.datasets[0].backgroundColor = chartColors.setAsides.slice(0, results.length);
donutChart.update();
// Update legend with descriptions
updateDonutLegend(results);
} catch (error) {
console.error(‘Error showing set-aside breakdown:’, error);
showError(‘Unable to fetch set-aside program data. Please try again.’);
// Reset to previous view
resetDonutChart();
} finally {
showLoading(false);
}
}
// Update donut chart legend with program descriptions
function updateDonutLegend(results) {
const legendEl = document.getElementById(‘donutLegend’);
legendEl.innerHTML = ”;
if (results.length > 0 && chartState.drilledIntoSetAside) {
results.forEach((result, idx) => {
const description = result.description || “Small business set-aside program”;
const color = chartColors.setAsides[idx % chartColors.setAsides.length];
const itemEl = document.createElement(‘div’);
itemEl.className = ‘state-legend-item’;
itemEl.innerHTML = `
${result.label}: ${description}
`;
legendEl.appendChild(itemEl);
});
} else {
legendEl.innerHTML = ”;
}
}
// Filter the line chart for a specific set-aside type
async function drillLineChartToSetAside(code, label) {
try {
showLoading(true);
// Create filter set for this set-aside type
const setAsideFilters = {
…currentFilters,
business_size: “S”
};
// Add set-aside filter if it’s a specific set-aside code (not OTHER_SMALL or OTHER)
if (code !== “OTHER_SMALL” && code !== “OTHER”) {
setAsideFilters.set_aside_type = [code];
}
// Filter further if a specific quarter is selected
if (chartState.selectedQuarter) {
const [fyStr, qStr] = chartState.selectedQuarter.split(‘-Q’);
const fy = parseInt(fyStr);
const q = parseInt(qStr);
// Set time period to just this quarter
setAsideFilters.time_period = [{
start_date: `${fy}-${q * 3 – 2}-01`,
end_date: `${fy}-${q * 3}-${q === 4 ? ’30’ : ’31’}`
}];
// Fetch data for just this quarter
const response = await fetchSpendingOverTime(setAsideFilters);
if (response.results && response.results.length > 0) {
const quarterData = response.results[0];
const quarterLabel = `${fy}-Q${q}`;
lineChart.data.labels = [quarterLabel];
lineChart.data.datasets = [
{
label: `${label} Contracts`,
data: [Math.round(quarterData.aggregated_amount)],
borderColor: chartColors.setAsides[0],
backgroundColor: `${chartColors.setAsides[0]}20`,
pointBackgroundColor: chartColors.setAsides[0],
borderWidth: 3,
fill: true
}
];
lineChart.update();
// Update line chart subtitle
document.getElementById(‘lineChartSubtitle’).textContent =
`Showing ${label} contracts for ${quarterLabel}`;
showLoading(false);
return;
}
}
// Fetch spending over time for this set-aside type
const response = await fetchSpendingOverTime(setAsideFilters);
if (!response.results || response.results.length === 0) {
throw new Error(‘No data found for the selected set-aside type’);
}
// Sort by fiscal year and quarter
response.results.sort((a, b) => {
if (a.time_period.fiscal_year !== b.time_period.fiscal_year) {
return a.time_period.fiscal_year – b.time_period.fiscal_year;
}
return a.time_period.quarter – b.time_period.quarter;
});
// Build chart data
const labels = response.results.map(r =>
`${r.time_period.fiscal_year}-Q${r.time_period.quarter}`
);
const amounts = response.results.map(r =>
Math.round(r.aggregated_amount)
);
// Update line chart
lineChart.data.labels = labels;
lineChart.data.datasets = [
{
label: `${label} Contracts`,
data: amounts,
borderColor: chartColors.setAsides[0],
backgroundColor: `${chartColors.setAsides[0]}20`,
pointBackgroundColor: chartColors.setAsides[0],
borderWidth: 3,
fill: true
}
];
lineChart.update();
// Update line chart subtitle
document.getElementById(‘lineChartSubtitle’).textContent =
`Showing quarterly spending for ${label} contracts`;
} catch (error) {
console.error(‘Error drilling down to set-aside:’, error);
showError(‘Unable to fetch set-aside time series data. Please try again.’);
resetLineChart();
} finally {
showLoading(false);
}
}
// Re-fetch donut data for a specific quarter
async function drillDonutToQuarter(fy, quarter) {
try {
showLoading(true);
const quarterStr = `${fy}-Q${quarter}`;
// Create quarter-specific filters
const quarterFilters = {
…currentFilters,
time_period: [{
start_date: `${fy}-${quarter * 3 – 2}-01`,
end_date: `${fy}-${quarter * 3}-${quarter === 4 ? ’30’ : ’31’}`
}]
};
// Get data for the specific quarter – small business
const smallBizFilter = {
…quarterFilters,
business_size: “S”
};
// Other-than-small business
const otherBizFilter = {
…quarterFilters,
business_size: “O”
};
// Fetch both datasets
const [smallBizData, otherBizData] = await Promise.all([
fetchSpendingOverTime(smallBizFilter),
fetchSpendingOverTime(otherBizFilter)
]);
// Calculate totals for the quarter
const smallBizAmount = smallBizData.results && smallBizData.results.length > 0
? Math.round(smallBizData.results[0].aggregated_amount)
: 0;
const otherAmount = otherBizData.results && otherBizData.results.length > 0
? Math.round(otherBizData.results[0].aggregated_amount)
: 0;
// Update donut chart
donutChart.data.labels = [‘Small Business’,’Other-than-Small’];
donutChart.data.datasets[0].data = [smallBizAmount, otherAmount];
donutChart.data.datasets[0].backgroundColor = [chartColors.small, chartColors.other];
donutChart.update();
// Clear any previous legend
document.getElementById(‘donutLegend’).innerHTML = ”;
// Update donut subtitle
document.getElementById(‘donutChartSubtitle’).textContent =
`Spending distribution for ${fy}-Q${quarter}`;
// Get contract counts for this quarter
const contractCountRequest = {
filters: quarterFilters,
category: “award_count”
};
const smallVendorRequest = {
filters: smallBizFilter,
category: “recipient_duns”
};
// Try to get contract and vendor counts
try {
const [countResponse, vendorResponse] = await Promise.all([
fetch(SPENDING_BY_CATEGORY_ENDPOINT, {
method: ‘POST’,
headers: { ‘Content-Type’: ‘application/json’ },
body: JSON.stringify(contractCountRequest)
}),
fetch(SPENDING_BY_CATEGORY_ENDPOINT, {
method: ‘POST’,
headers: { ‘Content-Type’: ‘application/json’ },
body: JSON.stringify(smallVendorRequest)
})
]);
if (countResponse.ok && vendorResponse.ok) {
const countData = await countResponse.json();
const vendorData = await vendorResponse.json();
const contractCount = countData?.results?.length > 0
? countData.results[0].count
: 0;
const smallVendorCount = vendorData?.results?.length || 0;
// Update KPIs
updateKPIs({
totalValue: smallBizAmount + otherAmount,
contractCount: contractCount,
smallBizValue: smallBizAmount,
smallVendorCount: smallVendorCount
});
} else {
// Use available data for KPIs
updateKPIs({
totalValue: smallBizAmount + otherAmount,
contractCount: 0,
smallBizValue: smallBizAmount,
smallVendorCount: 0
});
}
} catch (error) {
console.error(‘Error fetching contract counts for quarter:’, error);
// Update KPIs with available data
updateKPIs({
totalValue: smallBizAmount + otherAmount,
contractCount: 0,
smallBizValue: smallBizAmount,
smallVendorCount: 0
});
}
} catch (error) {
console.error(‘Error drilling down to quarter:’, error);
showError(‘Unable to fetch quarterly data. Please try again.’);
resetDonutChart();
} finally {
showLoading(false);
}
}
// Reset line chart to original view
function resetLineChart() {
lineChart.data = currentLineData;
lineChart.update();
// Reset subtitle
document.getElementById(‘lineChartSubtitle’).textContent =
‘Showing quarterly spending for the selected time period’;
// Reset chart state if focused on line chart
if (chartState.selectedQuarter) {
chartState.selectedQuarter = null;
// If we’re in set-aside mode, reload the set-aside donut
if (chartState.drilledIntoSetAside) {
showSetAsideBreakdown();
} else {
// Otherwise reset the donut chart too
resetDonutChart();
}
}
}
// Reset donut chart to original view
function resetDonutChart() {
donutChart.data = currentDonutData;
donutChart.update();
// Reset subtitle
document.getElementById(‘donutChartSubtitle’).textContent =
‘Showing distribution by business size’;
// Clear legend
document.getElementById(‘donutLegend’).innerHTML = ”;
// Reset drill-down state
chartState.drilledIntoSetAside = false;
// Refresh KPIs
refreshKPIs();
}
// Refresh KPIs to match current filter set
async function refreshKPIs() {
try {
showLoading(true);
// Calculate total values from current line chart data
const smallAmounts = lineChart.data.datasets[0].data;
const otherAmounts = lineChart.data.datasets.length > 1 ?
lineChart.data.datasets[1].data : [];
const totalSmall = smallAmounts.reduce((s,v)=>s+v,0);
const totalOther = otherAmounts.reduce((s,v)=>s+v,0);
// Update KPIs with totals
updateKPIs({
totalValue: totalSmall + totalOther,
contractCount: 0, // Will be updated by fetchContractCounts
smallBizValue: totalSmall,
smallVendorCount: 0 // Will be updated by fetchContractCounts
});
// Get contract and vendor counts
await fetchContractCounts(totalSmall, totalOther);
} catch (error) {
console.error(‘Error refreshing KPIs:’, error);
} finally {
showLoading(false);
}
}
// Update KPI values
function updateKPIs(data) {
document.getElementById(‘kpiTotal’).textContent = ‘ + formatMoney(data.totalValue);
document.getElementById(‘kpiContracts’).textContent = formatNumber(data.contractCount);
document.getElementById(‘kpiSmallBiz’).textContent = ‘ + formatMoney(data.smallBizValue);
document.getElementById(‘kpiSmallVendors’).textContent = formatNumber(data.smallVendorCount);
}
// Update chart subtitles
function updateChartSubtitles() {
// Extract year range from filters
const startDate = new Date(currentFilters.time_period[0].start_date);
const endDate = new Date(currentFilters.time_period[0].end_date);
const startYear = startDate.getFullYear();
const endYear = endDate.getFullYear();
const yearRange = endYear – startYear + 1;
const periodText = yearRange === 1 ? ‘year’ : `${yearRange} years`;
document.getElementById(‘lineChartSubtitle’).textContent =
`Showing quarterly spending for the past ${periodText}`;
document.getElementById(‘donutChartSubtitle’).textContent =
‘Showing distribution by business size’;
}
// Show/hide loading overlay
function showLoading(show) {
document.getElementById(‘loadingOverlay’).style.display = show ? ‘flex’ : ‘none’;
}
// Show/hide error message
function showError(message) {
const errorEl = document.getElementById(‘errorMessage’);
errorEl.textContent = message;
errorEl.style.display = ‘block’;
// Auto-hide after 8 seconds
setTimeout(() => {
errorEl.style.display = ‘none’;
}, 8000);
}
// Hide error message
function hideError() {
document.getElementById(‘errorMessage’).style.display = ‘none’;
}
// Number formatting helpers
function formatMoney(val) {
if (val >= 1e9) return (val/1e9).toFixed(1) + ‘B’;
if (val >= 1e6) return (val/1e6).toFixed(1) + ‘M’;
if (val >= 1e3) return (val/1e3).toFixed(0) + ‘K’;
return val.toLocaleString();
}
function formatNumber(val) {
if (val >= 1e6) return (val/1e6).toFixed(1) + ‘M’;
if (val >= 1e3) return (val/1e3).toFixed(1) + ‘K’;
return val.toLocaleString();
}